diff --git a/.env.example b/.env.example index d7b8e41..be7df77 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= # ── SNS 게재(스레드) ──────────────────────────────────────────────── # 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아 @@ -103,6 +108,14 @@ PUBLIC_WEB_BASE_URL=http://localhost # SITE_PUBLIC_HOST=web4ai.o2osolution.ai # 비우면 색인 통보를 건너뛴다(발행은 정상) INDEXNOW_KEY= + +# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md). +GSC_ENABLED=0 +GSC_PROPERTY_URL= +GSC_CREDENTIALS_FILE= +GSC_CREDENTIALS_HOST_FILE= +GSC_ALERT_DAYS=7 +GSC_ALERT_WEBHOOK_URL= # 비우면 로컬 발행만 한다 AZURE_STORAGE_CONNECTION_STRING= AZURE_STORAGE_CONTAINER= diff --git a/AGENTS.md b/AGENTS.md index ba5a7ca..6517214 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,8 @@ # AGENTS.md — 이 레포에서 작업하기 전에 +> 2026-09-15 발행 버전 전환: [docs/PUBLISH_VERSION.md](docs/PUBLISH_VERSION.md)가 +> 아래의 상시 프리렌더·재굽기·자산 주소 교체 절차를 대체한다. 목업은 변경하지 않는다. + 에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다. 설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다. @@ -34,7 +37,48 @@ → **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다** (`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다. → 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다. + → ★ **`stay` 는 프리렌더가 굽지 않는다** (`prerender.ts` `PROTECTED_SLUGS`, 기본 `stay` · + `PRERENDER_PROTECTED_SLUGS` 로 덮어쓴다). "payload 가 없으면 안 굽는다" + 는 보호가 못 된다 — **payload 가 생기는 순간** 덮인다. 실측(2026-09-15): 누가 빌더에서 + 슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자 프리렌더가 `/s/stay` 를 그 payload 로 + 구워 목업을 통째로 날렸다(캐치프레이즈 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). 클라이언트에서는 못 넘는다. + → 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다. + → 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다. + → ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다 + (아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…` + (`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의 + 이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`). + 열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후 + 호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.** + → `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 를 고치면 이름이 바뀐다. @@ -50,15 +94,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/deploy.sh b/deploy.sh index ba8c083..73738d8 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,127 +1,40 @@ #!/usr/bin/env bash -# 배포 — 코드를 당기고, 지정한 서비스만 다시 빌드해 갈아끼운다. -# -# ./deploy.sh 전체 -# ./deploy.sh solution-backend 그 서비스만 -# ./deploy.sh solution-backend solution-worker 여럿 -# -# 서비스명 대신 컨테이너명(o2o-web4ai-solution-backend)으로 불러도 받는다. +# 서버 변경과 꺼진 admin을 보존한다. 렌더러 배포도 기존 사이트를 재굽지 않는다. set -euo pipefail cd "$(dirname "$0")" - -PREFIX=o2o-web4ai -# api·worker·api-admin 은 이미지 한 벌(o2o-web4ai-backend)을 나눠 쓴다. -BACKEND_SVCS=(solution-backend solution-worker admin-backend) -BRANCH=${DEPLOY_BRANCH:-main} - PULL=1 ONLY=0 TARGETS=() - -usage() { - cat <<'USAGE' -사용법: ./deploy.sh [옵션] [서비스...] - - 옵션 - --no-pull 코드를 당기지 않는다(디스크에 있는 코드 그대로 빌드) - --only 백엔드 형제 서비스를 함께 갈아끼우지 않는다 (아래 ★ 참고) - -h, --help - - 환경변수 - DEPLOY_BRANCH 기본 main. 다른 브랜치를 배포할 때만 쓴다 - - 서비스: solution-backend · solution-worker · solution-prerender · solution-site - admin-backend · admin-frontend (프로필 admin, 기본 기동에서 빠져 있다) - solution-frontend (프로필 dev, 로컬 HMR 전용 — 운영에서 띄우지 않는다) - o2o-web4ai-solution-backend 처럼 컨테이너명으로 적어도 된다 - -★ solution-backend·solution-worker·admin-backend 는 이미지가 한 벌이다. 하나를 빌드하면 나머지도 새 이미지로 - 갈아끼워야 한다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데, 셋 다 "살아 있음" 이라 - 화면상으로는 배포가 끝난 것처럼 보인다. --only 는 그걸 알고 건너뛸 때만 쓴다. -USAGE -} - -while [ $# -gt 0 ]; do - case "$1" in +for arg in "$@"; do + case "$arg" in --no-pull) PULL=0 ;; - --only) ONLY=1 ;; - -h|--help) usage; exit 0 ;; - -*) echo "모르는 옵션: $1" >&2; usage >&2; exit 2 ;; - *) TARGETS+=("${1#"$PREFIX"-}") ;; # 컨테이너명으로 불러도 받는다 + --only) ONLY=1 ;; + -h|--help) echo "사용법: ./deploy.sh [--no-pull] [--only] [서비스...]"; exit 0 ;; + -*) echo "모르는 옵션: $arg" >&2; exit 2 ;; + *) TARGETS+=("${arg#o2o-web4ai-}") ;; esac - shift done - -ALL_SVCS=$(docker compose config --services) -for t in ${TARGETS+"${TARGETS[@]}"}; do - grep -qx "$t" <<<"$ALL_SVCS" || { - echo "그런 서비스가 없다: $t" >&2 - echo "있는 것: $(tr '\n' ' ' <<<"$ALL_SVCS")" >&2 - exit 2 - } -done - -# ── 코드 ──────────────────────────────────────────────────────────── -# 배포 서버의 작업트리는 main 의 **사본**이지 작업 공간이 아니다. 그래서 머지(pull)가 아니라 -# origin/$BRANCH 로 하드 리셋한다 — 밖에서 force-push 가 나도 --ff-only 로 막히지 않고, -# 서버에서 누가 손댄 흔적이 다음 배포까지 살아남지 않는다. -# -# ★ fetch 가 실패하면 **리셋하지 않는다.** 이 서버엔 gitea 자격증명이 없어 fetch 가 죽는데, -# 그 상태의 origin/$BRANCH 는 마지막으로 fetch 된 낡은 ref 다. -# 실측(2026-09-01 킹서버): HEAD=9b4fe40 인데 origin/main=4871e50 — 믿고 리셋하면 한 커밋 -# 롤백된다. 그러고도 빌드는 성공하고 컨테이너는 뜬다. 조용히 틀리는 종류다. -# -# ★ git clean 은 하지 않는다. .env 와 nginx/site.conf 는 추적되지 않는 파일이고 서버마다 -# 다르다 — reset --hard 는 이 둘을 건드리지 않지만 clean 은 지운다. if [ "$PULL" = 1 ]; then - echo "▶ git fetch origin $BRANCH" - if git fetch --prune origin "$BRANCH" 2>&1; then - DIRTY=$(git status --porcelain) - if [ -n "$DIRTY" ]; then - echo " ! 작업트리 변경을 버린다:" - sed 's/^/ /' <<<"$DIRTY" - fi - echo "▶ git reset --hard origin/$BRANCH" - git reset --hard "origin/$BRANCH" - else - echo " ! fetch 실패 — 리셋을 건너뛰고 디스크에 있는 코드 그대로 간다." >&2 - echo " origin/$BRANCH 가 낡았을 수 있어 그걸로 리셋하면 배포가 조용히 롤백된다." >&2 - echo " (밖에서 밀어넣었으면 이대로 두면 되고, 자동화하려면 gitea 배포키를 건다)" >&2 - fi + git diff --quiet && git diff --cached --quiet || { echo "서버 변경을 먼저 정리하세요" >&2; exit 1; } + git pull --ff-only origin "${DEPLOY_BRANCH:-main}" fi -echo "▶ 지금 코드: $(git log --oneline -1)" - -# ── 대상 정하기 ───────────────────────────────────────────────────── -if [ ${#TARGETS[@]} -eq 0 ]; then - SVCS=() # 빈 인자 = compose 가 전부로 해석한다 - echo "▶ 대상: 전체" -else - SVCS=("${TARGETS[@]}") - # 백엔드 하나를 건드리면 같은 이미지를 쓰는 형제도 함께 갈아끼운다. - if [ "$ONLY" = 0 ]; then - for t in "${TARGETS[@]}"; do - for b in "${BACKEND_SVCS[@]}"; do [ "$t" = "$b" ] || continue - for sib in "${BACKEND_SVCS[@]}"; do - printf '%s\n' "${SVCS[@]}" | grep -qx "$sib" || { - SVCS+=("$sib") - echo " + $sib — 같은 이미지를 쓴다(옛 코드로 남지 않게 함께 간다)" - } - done - done - done - fi - echo "▶ 대상: ${SVCS[*]}" +[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(solution-backend solution-worker solution-site) +RUNNING=$(docker compose ps --services --status running) +if [ "$ONLY" = 0 ]; then + case " ${TARGETS[*]} " in + *" solution-backend "*|*" solution-worker "*) + TARGETS+=(solution-backend solution-worker) + if grep -qx admin-backend <<<"$RUNNING"; then TARGETS+=(admin-backend); fi ;; + esac fi - -# ── 빌드 · 교체 ───────────────────────────────────────────────────── -# build 절이 없는 서비스(node:24-alpine · nginx:alpine)는 build 가 조용히 건너뛴다. -echo "▶ build" -docker compose build ${SVCS+"${SVCS[@]}"} - -echo "▶ up -d" -docker compose up -d --force-recreate ${SVCS+"${SVCS[@]}"} - -echo +SERVICES=() +for target in "${TARGETS[@]}"; do + case " ${SERVICES[*]-} " in *" $target "*) ;; *) SERVICES+=("$target");; esac +done +# site의 렌더러도 worker 이미지에 들어간다. +case " ${SERVICES[*]} " in + *" solution-site "*) case " ${SERVICES[*]} " in *" solution-worker "*) ;; *) SERVICES+=(solution-worker);; esac ;; +esac +docker compose build "${SERVICES[@]}" +docker compose up -d --no-deps --force-recreate "${SERVICES[@]}" docker compose ps -echo -echo "로그: ./log.sh" diff --git a/docker-compose.search-console.yml b/docker-compose.search-console.yml new file mode 100644 index 0000000..c3f01d9 --- /dev/null +++ b/docker-compose.search-console.yml @@ -0,0 +1,13 @@ +# 선택 연동. 키 파일은 저장소 밖에 두고 기존 API 스케줄러에만 읽기 전용으로 전달한다. +# docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d solution-backend +services: + solution-backend: + environment: + GSC_CREDENTIALS_FILE: /run/secrets/search-console.json + volumes: + - type: bind + source: ${GSC_CREDENTIALS_HOST_FILE:?Search Console 키 파일 절대경로 필요} + target: /run/secrets/search-console.json + read_only: true + bind: + create_host_path: false diff --git a/docker-compose.yml b/docker-compose.yml index 64e3ba9..77b5dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,8 +28,12 @@ x-common-env: &common-env # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가 # 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다). SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost} - SITE_PAYLOAD_DIR: /app/out/payloads - SITE_OUTPUT_DIR: /app/out/sites + # ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치 + # 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그 + # 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리 + # (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다. + SITE_PAYLOAD_DIR: /app/solution/site/payloads + SITE_OUTPUT_DIR: /app/solution/site/out # ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다. INDEXNOW_KEY: ${INDEXNOW_KEY:-} @@ -48,10 +52,10 @@ services: # ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다. SCHEDULER_ENABLED: "1" volumes: - - ./solution/site/payloads:/app/out/payloads - # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가 - # 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts). - - ./solution/site/songs:/app/out/songs + - ./solution/site/payloads:/app/solution/site/payloads + # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를 + # 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts). + - ./solution/site/songs:/app/solution/site/songs # ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고, # 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다. - ./postgres-init:/app/postgres-init:ro @@ -64,13 +68,17 @@ services: driver: json-file options: { max-size: "10m", max-file: "5" } + # ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다 + # (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다. + # 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러 + # 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다. solution-worker: build: context: . - dockerfile: solution/backend/Dockerfile - image: o2o-web4ai-backend + dockerfile: solution/backend/Dockerfile.worker + image: o2o-web4ai-worker container_name: o2o-web4ai-solution-worker - command: ["python", "worker_main.py"] + command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"] env_file: - .env environment: @@ -79,15 +87,20 @@ services: WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1} JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900} JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120} + # 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의 + # job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다. + RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180} # 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다. healthcheck: disable: true volumes: - - ./solution/site/payloads:/app/out/payloads - # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가 + - ./solution/site/payloads:/app/solution/site/payloads + # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더러가 # 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts). - - ./solution/site/songs:/app/out/songs - - site-out:/app/out/sites:ro + - ./solution/site/songs:/app/solution/site/songs + # ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가 + # 쓰던 자리를 그대로 이어받는다). + - site-out:/app/solution/site/out extra_hosts: - "host.docker.internal:host-gateway" stop_grace_period: 300s @@ -124,10 +137,10 @@ services: start_period: 20s retries: 3 volumes: - - ./solution/site/payloads:/app/out/payloads + - ./solution/site/payloads:/app/solution/site/payloads # 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가 # 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts). - - ./solution/site/songs:/app/out/songs + - ./solution/site/songs:/app/solution/site/songs ports: # ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다. - "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801" @@ -140,48 +153,6 @@ services: driver: json-file options: { max-size: "10m", max-file: "5" } - # 발행 사이트를 굽는다. **굽기만 한다** — 서빙은 solution-site(nginx)가 맡는다. - # - # ★ 사장님 앱 dev 서버는 여기서 빠졌다. 운영에 `vite dev` 를 띄우면 요청마다 트랜스파일하고 - # 기동이 npm install 네트워크에 의존하고 /src 원본이 그대로 나간다. 번들은 solution-site - # 이미지가 굽는다(nginx/Dockerfile). 로컬에서 HMR 이 필요하면 `--profile dev`. - solution-prerender: - image: node:24-alpine - container_name: o2o-web4ai-solution-prerender - working_dir: /app - command: - - sh - - -c - - | - cd /app - # ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서 - # 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다. - [ -x node_modules/.bin/vite ] || npm install - exec node solution/site/scripts/watch-payloads.mjs - environment: - INDEXNOW_KEY: ${INDEXNOW_KEY:-} - # ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를 - # 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. - VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost} - volumes: - - ./package.json:/app/package.json - - ./package-lock.json:/app/package-lock.json - - ./tsconfig.base.json:/app/tsconfig.base.json - - ./solution:/app/solution - - ./admin:/app/admin - # ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의 - # rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다. - - /app/node_modules - - /app/solution/site/node_modules - - /app/solution/frontend/node_modules - - /app/admin/frontend/node_modules - # ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다. - - site-out:/app/solution/site/out - restart: unless-stopped - logging: - driver: json-file - options: { max-size: "10m", max-file: "5" } - # 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`. # 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석). solution-frontend: @@ -294,7 +265,66 @@ services: # 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다. - "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80" depends_on: - - solution-prerender + - solution-backend + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + + # ── 온톨로지(o2o-site-ontology) — 발행본 메타 키워드·제목 업종어 ────────────── + # ★ 자체 DB 가 따로 있다. 호스트 postgres 를 같이 쓰지 않는 이유는 pgvector 확장이 필요해서다. + # 우리 DB 에 확장을 걸면 web4ai_db 가 그 확장에 묶인다 — 남의 서비스 사정을 우리 DB 가 떠안는다. + ontology-postgres: + image: pgvector/pgvector:pg16 + container_name: o2o-web4ai-ontology-postgres + environment: + POSTGRES_USER: ontology + POSTGRES_PASSWORD: ontology + POSTGRES_DB: ontology + volumes: + - ontology-pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology'] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + ontology-redis: + image: redis:7-alpine + container_name: o2o-web4ai-ontology-redis + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + ontology: + build: + context: ./ontology + container_name: o2o-web4ai-ontology + environment: + PORT: "3100" + # 컨테이너끼리는 서비스 이름으로 만난다 — 호스트 포트(55432·56379)는 사람이 들여다볼 때만 쓴다. + DATABASE_URL: postgres://ontology:ontology@ontology-postgres:5432/ontology + REDIS_HOST: ontology-redis + REDIS_PORT: "6379" + # API 키 없이 도는 기본값(로컬 임베딩 + mock LLM). 키를 쓰려면 .env 에서 덮어쓴다. + EMBEDDING_PROVIDER: ${ONTOLOGY_EMBEDDING_PROVIDER:-local} + EMBEDDING_LOCAL_MODEL: ${ONTOLOGY_EMBEDDING_MODEL:-Xenova/multilingual-e5-small} + LLM_PROVIDER: ${ONTOLOGY_LLM_PROVIDER:-mock} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + volumes: + # 임베딩 모델 캐시. 볼륨이 없으면 컨테이너를 새로 만들 때마다 120MB 를 다시 받는다. + - ontology-model:/app/.cache + ports: + - "127.0.0.1:3100:3100" + depends_on: + ontology-postgres: + condition: service_healthy + ontology-redis: + condition: service_healthy restart: unless-stopped logging: driver: json-file @@ -303,3 +333,6 @@ services: volumes: # ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다. site-out: + # 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다. + ontology-pgdata: + ontology-model: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46ef371..73b51e1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,8 @@ # ARCHITECTURE +> 2026-09-15: 현재 발행 실행·산출물 버전·배포는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md). +> 아래의 별도 프리렌더 컨테이너·폴링·전체 재굽기 설명은 이전 구조다. + 제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md), 에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다. @@ -37,7 +40,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 ★ 백엔드의 유일한 산출물 │ @@ -66,6 +72,10 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build() ## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다 +Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해 +사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다. +선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다. + **결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.** Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** — `AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index ec6806d..9b83f95 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -7,6 +7,8 @@ 여기서는 그 앞뒤를 잇는다. - 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md). - 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md). +- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다. + 컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`) 과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다. @@ -124,6 +126,23 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 · 활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스). 후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다. +**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`). +예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로 +막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는 +값이라 승인할 화면을 이미 지나가 있었다. + +지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다. + +| 그 key 의 현재 노출값 | 수집값이 오면 | +|---|---| +| 없음 | 바로 노출값(VERIFIED). `verified_by` 는 **비운다** — 사람이 승인한 이력과 구별된다 | +| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 | +| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 | +| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) | + +즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다. +UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다. + ### `place_channels` — 크롤링 대상 URL `confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다. @@ -137,8 +156,16 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 · ### `place_faqs` — FAQ -`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이 -데이터 모양으로 강제된 자리다. +출처(`generated_by`)마다 근거 요구가 다르다. + +| generated_by | 무엇 | source_fact_ids | 어디에 나가나 | +|---|---|---|---| +| `LLM`(4) | 확인된 fact 로 쓴 문장 | 근거 key 필수 — 없으면 저장하지 않는다(`copy_service`) | 화면 · JSON-LD · llms.txt | +| `OWNER`(1) | 사장님이 쓰거나 고친 문장 | 없을 수 있다 | 화면 · JSON-LD · llms.txt | +| `TEMPLATE`(5) | 20개를 채운 공통 질문 + 문의 안내 답 | 없음 | **화면만** | + +★ 예전 문서는 "비면 발행 게이트가 반려한다" 고 적었지만 그런 검사는 없었다(2026-09-14 확인). +근거 강제는 저장 시점(`copy_service`)에 있다. 채우기 규칙은 [DECISIONS 8절](DECISIONS.md). ### `place_songs` — 이 숙소의 노래 @@ -184,7 +211,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` 안에 있다. @@ -242,6 +269,9 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다. ### `jobs` — 작업 큐 (PostgreSQL 을 큐로) +COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다. +생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md). + | `job_type` | 핸들러 | 하는 일 | |---|---|---| | 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index da993e6..5b19e25 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -34,6 +34,25 @@ | 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** | | 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 | +**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.** +위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해** +연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다. + +| | 지금 | +|---|---| +| `nol.yanolja.com/stay/domestic/` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 | +| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 | +| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 | + +- 레지스트리가 `yanolja` 를 `static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고 + 나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다. +- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html` 의 `_DENY_HOSTS` 에서 `yanolja.com` · + `goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가** + 두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른 + 일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다. +- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다** — + 이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다. + ### 1-2. 크롤링한 **이미지**의 재게시 권리 2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다. @@ -351,3 +370,30 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 남은 정책: 만료 24시간의 최종 근거, 야간 발송(현재 화면 채널만 사용), 다계정 선택, 장기 미사용 계정의 사전 토큰 갱신. 계정은 현재 user/provider당 하나다. + +## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14) + +**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건, +산하연 풀빌라 fact 4건 · FAQ 4건. + +**어떻게** +- 생성 상한 `max_faqs` 8 → 20 (`services/faq_fill.FAQ_TARGET`). +- 노출 중 FAQ 가 20개에 모자라면 업종 카탈로그(`common/faq_catalog/resources/pension.json`, 30문항)에서 + **겹치지 않는** 질문을 카탈로그 순서대로 고른다. 건너뛰는 것: + - 답할 fact 가 있는 질문 — LLM 이 fact 로 답할 자리다. 프롬프트에 그 질문들을 실어 먼저 쓰게 한다. + - 기존 FAQ(생성분·사장님 입력·정정분)와 **근거 fact key** 가 겹치거나 **질문 키워드**가 겹치는 질문. + key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다. +- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다. + 업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다 + (`frontend … canvas/variants/faq/useFaqList.ts` 주석). +- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다. +- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다. + 그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 — + 이제 그 거절은 **카탈로그가 없는 업종**(카페·음식점·호텔)에만 남는다. + +**어디에 안 나가나** — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ +점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라 +고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다. + +**적용 범위** — 숙박 업종이면서 외부 분류(`places.external_category`)가 호텔·모텔·리조트가 아닌 곳. +분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9c180f5..1d82214 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,5 +1,8 @@ # 배포 · 스토리지 +> 2026-09-15 이후 절차는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md)를 따른다. +> 기존 사이트 전체 재굽기는 하지 않는다. 최초 전환 때 구 프리렌더를 중지한다. + > **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.** > Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움). > 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md). diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2e02784..31f7109 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,10 +1,130 @@ # 개발 일지 +## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기 + +- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다. +- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다. +- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다. +- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md). +- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다. +- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다. +- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과. + 무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. --- +## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측 + +- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림. +- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다. +- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다. +- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행. + +**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현). + +## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구 + +- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거. +- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지. +- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리. +- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md). + +**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과. + +--- + +## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다) + +**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를 +발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고, +화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다. + +**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted) +`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다. +CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 +그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다. + +→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고 + 저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다. +→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고, + 발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s//img/` 에 두고 payload 주소를 바꾼다) + 저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.** + +--- + +## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) + +**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 +겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다. +``` +생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분) + → 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀 + → "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED) +``` + +**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). + +**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면 +업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 — +FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다. + +**바꾼 곳** +- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외. +- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다. +- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4). +- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT). + `generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`). + init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다. +- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다. +- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가 + (노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다). +- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만), + `run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다. +- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다. +- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시. + +**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는 +fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md). + +**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침). +실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다. +site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed. +로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0. +0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과. + +--- + +## 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-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다 **추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서 diff --git a/docs/GENERATION_FLOW.md b/docs/GENERATION_FLOW.md new file mode 100644 index 0000000..78fdb27 --- /dev/null +++ b/docs/GENERATION_FLOW.md @@ -0,0 +1,46 @@ +# 콘텐츠 생성 · 진행 복구 + +2026-09-15. `builder?step=generating`은 COPY(소개문·FAQ) 작업이다. +사진 분석은 VISION, 정적 사이트·노래 생성은 발행 BUILD에 속한다. + +```text +템플릿 선택 → POST /v1/place/{placeId}/copy → jobId를 URL에 기록 + ↓ +새로고침 ──────────────────────→ GET /v1/job/{jobId} + ↑ +COPY 워커: prepare → generate → save → faq_fill + 각 단계 진입·완료 → jobs.progress(JSONB) + ↓ +화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor +``` + +| 책임 | 파일 | +|---|---| +| 실행 순서 | `solution/backend/services/copy_service.py` | +| 단계 구현 | `solution/backend/services/copy_steps.py` — `prepare_copy`, `generate_copy`, `save_copy`, `fill_faqs` | +| 프롬프트·응답 스키마 | `solution/backend/services/prompts/copy.py` | +| 모델 호출·생성물 검증 | `solution/backend/services/external/gemini_text.py` → `llm/gemini.py`, `grounding/copy.py` | +| 단계 기록 | `solution/backend/services/job_progress.py` → `crud/job_crud.py` | +| API 계약 | `solution/backend/router/v1/job/protocol.py` → OpenAPI → Orval | +| 조회·복구·완료 전환 | `solution/frontend/src/features/onboarding/useGenerationJob.ts` | +| 화면 / 문구 | 같은 폴더의 `Step5Generating.tsx` / `generationLabels.ts` | + +- `jobs.status`는 작업 전체 상태, `progress.steps[].status`는 단계 상태다. + 단계는 `pending/running/done/skipped/failed`. 시간으로 퍼센트나 단계를 올리지 않는다. +- `progress.attempt`는 워커 시도 번호다. 재시도는 단계를 처음부터 다시 기록한다. + 기록은 실행 중인 워커·시도 번호·유효한 lease가 일치할 때만 허용한다. +- 새로고침은 GET만 한다. jobId가 없는 구 URL은 `POST copy {resume: true}`로 + 해당 사업장의 최근 COPY를 찾는다. DONE·DEAD도 반환하므로 완료됐다고 새 작업을 만들지 않는다. + 권한 검사는 사업장 조회가 먼저 한다. URL로 조회하는 COPY도 소유자를 검사한다. +- 템플릿의 생성 버튼을 명시적으로 누르면 기본 POST로 새 작업을 요청한다. + 같은 사업장의 활성 작업이 있으면 기존 중복 방지 규칙으로 그 작업에 연결한다. +- 통신 오류는 상태 재조회, DEAD는 이전 단계 또는 편집기로 직접 이동할 수 있다. + 오류·대기·미설정 상태를 가짜 진행이나 완료 화면으로 바꾸지 않는다. +- 노래 단계는 이번 COPY 흐름에 추가하지 않았다. 발행 BUILD 진행 표시 확장은 별도다. + +적용: 마이그레이션 `0013_job_progress.sql`을 먼저 적용한 뒤 API·워커·빌더를 배포한다. +기존 잡의 `progress`는 NULL이다. 이 경우 단계 목록을 지어내지 않고 전체 상태만 표시한다. + +검증: `tests/test_copy_api.py`, `tests/test_job_queue.py`, `tests/test_schema_ddl.py`. +프론트는 개발 서버를 켜고 `node solution/frontend/tests/generation.mjs <개발 URL>` 실행. +브라우저 테스트는 모든 API를 가짜 응답으로 대체한다. diff --git a/docs/PUBLISH_VERSION.md b/docs/PUBLISH_VERSION.md new file mode 100644 index 0000000..89a117b --- /dev/null +++ b/docs/PUBLISH_VERSION.md @@ -0,0 +1,32 @@ +# 발행 버전과 워커 (2026-09-15) + +이 문서가 이전 문서의 프리렌더 상시 기동·전체 재굽기 절차를 대체한다. + +`BUILD → snapshot → payload → Node 렌더 → 결과 게이트 → 공개 링크 전환 → DB 기록` + +- Python은 HTML을 만들지 않는다. 미리 컴파일된 Node를 실행하고 JSON 보고서만 읽는다. +- 워커 이미지에 Node와 렌더러를 포함한다. 실행 중 npm 설치·번들 빌드는 없다. +- 공유 볼륨의 파일 잠금으로 렌더·공개 전환을 직렬화한다. 수집 등 다른 잡은 동시 실행한다. +- `out/versions//`에 성공한 HTML과 렌더 보고서를 보존한다. +- `out/s/`는 공개 버전의 상대 심볼릭 링크다. 게이트 통과 후 전환한다. +- 성공한 버전은 재시도·롤백 때 다시 쓰지 않는다. 기존 일반 디렉토리는 첫 재발행 때 legacy로 보존한다. +- 배포 시 HTML·기존 HTML의 자산 주소를 수정하지 않는다. 공용 자산과 미리보기 셸만 준비한다. +- 목업과 보관 버전의 참조 자산도 삭제 대상에서 제외한다. versions는 Azure 공용 업로드에서 제외한다. +- 롤백 API: `POST /v1/place/{place_id}/site/version/rollback`, `target_version`. 소유권 검사와 BUILD 중복 방지 키를 공유한다. +- 로컬 공개 전환과 DB/Azure는 단일 트랜잭션이 아니다. 외부 저장소·DB 실패 시 재시도 및 운영 확인이 필요하다. +- 최초 일반 디렉토리→링크 전환은 두 rename 사이 짧은 공백이 가능하다. 이후 링크 교체는 원자적이다. + +## 배포 + +1. 진행 중 잡·서버 변경·목업 및 참조 자산 해시를 확인하고 site-out을 백업한다. +2. backend·worker·site 이미지를 빌드한다. admin은 기본 대상이 아니다. +3. 기존 worker와 solution-prerender를 중지한 뒤 새 worker를 기동한다. 두 렌더러를 동시에 실행하지 않는다. +4. API·미리보기·테스트 발행을 확인하고 목업 해시를 대조한다. 전체 재굽기·republish_all은 실행하지 않는다. + +워커 경로: `SITE_PAYLOAD_DIR=/app/solution/site/payloads`, `SITE_OUTPUT_DIR=/app/solution/site/out`. +DB 테이블 추가는 없다. 기존 버전·잡·발행 로그를 사용한다. + +## UI + +예약 전 확인과 요약을 이용안내 및 예약에 통합한다. 별도 요약 섹션과 예약 카드의 중복 규정은 제거한다. +빌더 미리보기는 iframe 내부 렌더 완료 신호까지 스피너를 표시한다. 출처·iframe을 확인하고 12초 상한을 둔다. diff --git a/docs/SEARCH_CONSOLE.md b/docs/SEARCH_CONSOLE.md new file mode 100644 index 0000000..3ffb972 --- /dev/null +++ b/docs/SEARCH_CONSOLE.md @@ -0,0 +1,99 @@ +# Google Search Console 자동 추적 + +`발행 DB 감지 → 공개 사이트맵 확인/제출 → 색인 조회 → 상태 저장·Teams 알림` + +## 경계 + +- 기존 API의 스케줄러에서 10분마다 실행한다. 컨테이너 추가 없음. +- `sites.status=PUBLISHED`인 사이트만 등록하므로 초안/목업 디렉토리 나열을 작업 원장으로 쓰지 않는다. +- 발행 DB에서 재발견한다. 발행 순간 별도 큐 적재가 실패하는 틈이 없고 재시작해도 이어진다. +- 발행 트랜잭션/잡과 독립적이다. Google 실패가 사이트 발행을 실패로 바꾸지 않는다. +- 한 번에 신규 발행 100개 등록, 조회는 오래 기다린 5개 처리. 정상 조회는 24시간 후 반복. +- 현재 렌더러의 단일 루트 urlset만 지원하고 읽기 상한은 5MB다. 향후 sitemap index 분할 시 확장한다. +- 오류는 1·2·4·8·16·24시간 간격 재시도. 기본 주기 기준 하루 최대 720회 검사이며, + 다른 도구의 같은 속성 사용량도 Google 할당량에 포함된다. 대량 백로그는 여러 날에 걸쳐 소진한다. +- PostgreSQL transaction advisory lock으로 다중 API 프로세스의 동시 배치를 막는다. + 단일 배치는 외부 호출 동안 트랜잭션/연결 1개를 점유한다(검사 1건 최대 90초, 최대 5건). +- 사이트맵 제출 성공과 URL 색인 성공은 별개다. `first_indexed_at`은 **우리가 처음 PASS를 관측한 시각**이다. + Google 내부 색인 시각이나 최신 발행 버전 반영 시각이 아니다. 원본 `lastCrawlTime`도 함께 보관한다. +- 재발행 시 해당 발행의 관측 상태를 초기화한다. 지난 관측 이력 전체를 누적하는 이벤트 저장소는 아니다. +- `SITE_PUBLIC_HOST` 변경은 기존 지침대로 재발행이 필요하다. 사이트 주소의 단일 출처는 `site_payload`다. + +## 최초 설정 (운영자) + +1. Search Console에서 발행 도메인의 소유권 확인. URL-prefix 속성이면 + `https://web4ai.o2osolution.ai/`, 도메인 속성이면 `sc-domain:web4ai.o2osolution.ai` 형태. +2. Google Cloud에서 Search Console API 활성화, 전용 서비스 계정 생성. +3. Search Console 속성 설정 → 사용자 및 권한에서 그 서비스 계정 이메일에 전체 사용자 권한 부여. + Google 로그인용 `GOOGLE_CLIENT_ID`와는 다른 인증이다. +4. 서비스 계정 JSON 키는 **저장소 밖**에 보관한다. 권한을 최소화하고 git/이미지/로그에 넣지 않는다. +5. 루트 `.env` 설정: + +```dotenv +GSC_ENABLED=1 +GSC_PROPERTY_URL=https://web4ai.o2osolution.ai/ +GSC_CREDENTIALS_HOST_FILE=/secure/location/search-console.json +GSC_ALERT_DAYS=7 +GSC_ALERT_WEBHOOK_URL= +``` + +키 생성/권한 부여/실제 알림 전송은 구현 검증 중 자동 수행하지 않는다. + +## 배포 + +먼저 새 이미지에 requirements를 설치하고 `0014_search_console.sql`을 기존 마이그레이션 도구로 적용한다. +프로젝트 전체 마이그레이션 순서를 확인한 뒤 실행한다. 아래는 운영자가 실행할 명령이며 자동 배포하지 않았다. + +```bash +docker compose exec -T solution-backend python scripts/migrate.py +docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d --build solution-backend +``` + +선택 compose 파일은 API에만 키를 읽기 전용 마운트하고 `GSC_CREDENTIALS_FILE`을 설정한다. +없는 파일을 디렉토리로 자동 생성하지 않는다. 이후 배포에서도 이 override를 함께 사용해야 한다. +로컬 Python 실행은 `GSC_CREDENTIALS_FILE`에 로컬 키 파일 경로를 지정한다. +켜진 스케줄러는 첫 10분 주기부터 기존 발행 사이트도 등록한다. `GSC_ENABLED=0`이면 DB/Google 호출 모두 생략한다. + +## 알림 + +Teams Workflows의 webhook 수신 → 채널에 Adaptive Card 게시 흐름 URL을 +`GSC_ALERT_WEBHOOK_URL`에 넣는다. 비우면 외부 전송 없이 경고 로그/DB만 남는다. +API/사이트맵 오류 또는 발행 후 기본 7일 미색인 시 알린다. 성공한 알림은 사이트별 24시간 중복 억제. +전송 실패는 `alerted_at`을 갱신하지 않아 다음 검사 때 재시도한다. +외부 전송 후 DB commit 전에 죽으면 중복 알림이 가능하다(at-least-once). +키·토큰·webhook URL·Google 오류 본문은 알림에 포함하지 않는다. + +## 결과 확인 + +```bash +docker compose exec -T solution-backend python scripts/search_console_status.py +``` + +읽기 전용이며 Google API를 추가 호출하지 않는다. 프론트 화면/API 계약은 변경하지 않았다. + +| 파일 | 책임 | +|---|---| +| `services/search_console_client.py` | 인증·Google HTTP·오류 정규화 | +| `services/search_console_settings.py` | 선택 설정·속성 URL 범위 | +| `services/search_console_service.py` | 배치 흐름·재시도·관측 결과 | +| `crud/search_console_crud.py` | 발행 감지·등록·조회 순서·동시 실행 잠금 | +| `services/search_console_alerts.py` | 알림 조건·Teams 전송 | + +## 구글 지원 범위 / 남은 운영 작업 + +- [사이트맵 제출 API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)는 지원된다. +- [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)는 + Google이 이미 알고 있는 상태 조회용이며 실시간 페이지 테스트나 색인 요청 API가 아니다. +- 일반 숙박 사이트는 [Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api) 대상이 아니다. +- [검사 할당량](https://developers.google.com/webmaster-tools/limits)은 속성당 하루 2,000회다. +- [Teams webhook 형식](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook). +- 실제 서비스 계정 권한/사이트맵 제출/색인 관측/Teams 수신은 설정 후 운영 검증이 필요하다. +- 기존 루트 사이트맵의 백업 URL 정리와 IndexNow 개별 사이트맵 참조 문제는 이 기능과 별도다. + 이 기능은 기존 공개 사이트맵을 제출하며 내용을 다시 만들거나 목업을 삭제하지 않는다. + +## 구현 검증 (2026-09-15) + +- 격리 PostgreSQL에서 클라이언트·배치·스키마·IndexNow 관련 59건 통과. +- 발행·설정·사이트 목록 회귀검사: 23건 통과, `test_unverified_fact_blocks_publish` 1건 실패. + 해당 실패는 변경 전 HEAD `9773bc0`의 발행 코드에서도 동일 재현됨(GSC 비활성). +- Google/Teams 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상. diff --git a/docs/SEARCH_CONSOLE_CLIENT.md b/docs/SEARCH_CONSOLE_CLIENT.md new file mode 100644 index 0000000..df43dfb --- /dev/null +++ b/docs/SEARCH_CONSOLE_CLIENT.md @@ -0,0 +1,71 @@ +# Search Console 클라이언트 + +`solution/backend/services/search_console_client.py` — Google Search Console 에 +사이트맵을 제출하고 URL 색인 상태를 조회하는 REST 클라이언트만 다룬다. +DB 저장·스케줄링·발행 감지·환경 설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)를 따른다. + +공식 문서: [Sitemaps.submit](https://developers.google.com/webmaster-tools/v1/sitemaps/submit) · +[urlInspection.index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect) + +## 1. 계약 + +```python +class SearchConsoleClient: + def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): ... + async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: ... + async def inspect_url(self, property_url: str, page_url: str) -> dict: ... # indexStatusResult 만 + async def aclose(self) -> None: ... + # async with SearchConsoleClient(...) as client: ... +``` + +- `credentials_file`: 서비스 계정 JSON 키 파일 경로. +- `transport`: 테스트에서 `httpx.MockTransport` 를 꽂는 자리 — 실제 Google 호출 없이 검증한다. +- `inspect_url` 은 응답의 `inspectionResult.indexStatusResult` 만 돌려준다. 그 경로가 + 없거나(검사 실패) 빈 dict 면(실제 검사가 안 된 응답) `SearchConsoleError` 를 올린다 — + **"미색인"으로 넘겨짚지 않는다.** + +## 2. 인증 + +서비스 계정 JSON 키 파일 + scope `https://www.googleapis.com/auth/webmasters`. +`google.oauth2.service_account` · `google.auth.transport.requests.Request` · `requests` 는 +전부 함수 안에서 import 한다. 두 패키지는 백엔드 `requirements.txt`에 포함되어 있다. + +- 토큰은 클라이언트 인스턴스에 캐시된다(`credentials.valid` 인 동안 재사용, 매 호출 + 갱신하지 않는다). 동시 호출은 `asyncio.Lock` 으로 갱신을 한 번만 태운다. +- 갱신은 `asyncio.to_thread` 로 별도 스레드에서 돈다. 내부 `requests.Session` 요청에는 + 타임아웃을 강제로 20초로 덮어씌운다(`Request.__call__` 기본값 120초를 무시) — 만료된 + 키·막힌 네트워크에서 무한정 걸리는 것을 막는다. + +## 3. 오류 — `SearchConsoleError(code)` + +`code` 문자열 하나만 들고 다닌다. **Google 응답 본문·액세스 토큰·키 파일 내용·원본 예외 +메시지는 절대 담지 않는다** — 로그·잡 상태·관리 화면 어디로 흘러도 안전하다. + +| code | 뜻 | +|---|---| +| `invalid_credentials_file` | 키 파일을 못 읽거나 형식이 잘못됨 | +| `auth_failed` | 토큰 갱신 실패, 또는 갱신 후에도 토큰이 비어 있음 | +| `unauthorized` | HTTP 401 | +| `forbidden` | HTTP 403 | +| `rate_limited` | HTTP 429 | +| `server_error` | HTTP 5xx | +| `http_` | 그 외 실패 상태코드 | +| `timeout` | 요청 타임아웃 | +| `transport_error` | 그 외 전송 실패(연결 끊김 등) | +| `invalid_json` | 200 인데 본문이 JSON 이 아님 | +| `missing_inspection_result` | 응답에 `inspectionResult` 가 없음 | +| `missing_index_status_result` | `inspectionResult` 는 있는데 `indexStatusResult` 가 없거나 빈 dict | + +## 4. 테스트 + +```bash +cd solution/backend +APP_ENV=test .venv/bin/python -m pytest tests/test_search_console_client.py --confcutdir=tests +``` + +`--confcutdir=tests` 가 필요한 이유: 저장소 루트 `conftest.py` 의 세션 스코프 autouse +픽스처가 실 Postgres 연결을 요구한다(`solution/backend/conftest.py`). 이 클라이언트 +테스트는 DB 를 전혀 쓰지 않으므로 그 픽스처를 건너뛴다 — `--confcutdir=tests` 로 상위 +`conftest.py` 탐색을 끊는다. (통합 후 전체 스위트를 돌릴 때는 이 플래그 없이 실행한다.) + +Google 실 API 는 전부 `httpx.MockTransport` 로 막았다 — 네트워크 호출도, 과금도 없다. diff --git a/docs/TEAMS_WEBHOOK.md b/docs/TEAMS_WEBHOOK.md new file mode 100644 index 0000000..8d106e0 --- /dev/null +++ b/docs/TEAMS_WEBHOOK.md @@ -0,0 +1,11 @@ +# Teams 웹훅 확인 (2026-09-15) + +- Adaptive Card 요청의 `contentUrl: null` 및 `$schema`를 공식 예제에 맞춰 보완했다. +- HTTP 202는 워크플로의 요청 접수다. Teams 채널 게시 성공을 뜻하지 않는다. +- 실제 전송 2건은 202였지만 사용자가 확인한 워크플로 실행은 실패였다. + 상세 오류를 확인하지 못했으므로 누락 필드를 실제 실패 원인으로 단정하지 않는다. +- 운영 자동 알림 활성화 전, 채널 수신 또는 워크플로의 최종 게시 단계 성공을 확인해야 한다. +- 웹훅은 `.env`에만 보관하고 커밋하지 않는다. + +검증: 백엔드에서 `APP_ENV=test PYTHONPATH=. .venv/bin/pytest tests/test_search_console_alerts.py --confcutdir=tests`. +공식 형식: https://learn.microsoft.com/en-us/connectors/teams/#adaptivecarditemschema diff --git a/docs/WEATHER.md b/docs/WEATHER.md new file mode 100644 index 0000000..5b1ad0c --- /dev/null +++ b/docs/WEATHER.md @@ -0,0 +1,17 @@ +# 오늘의 날씨 + +`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection` + +관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과 +관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. + +`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets` + +하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, +브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다. +기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다. + +목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지 +않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.** +지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다. +옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다. diff --git a/ontology/.dockerignore b/ontology/.dockerignore new file mode 100644 index 0000000..beed316 --- /dev/null +++ b/ontology/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.env +*.log diff --git a/ontology/.env.example b/ontology/.env.example new file mode 100644 index 0000000..4b6f0b4 --- /dev/null +++ b/ontology/.env.example @@ -0,0 +1,37 @@ +# --- server --- +PORT=3100 + +# --- postgres (docker-compose 기본값) --- +DATABASE_URL=postgres://ontology:ontology@localhost:55432/ontology + +# --- redis (BullMQ) --- +REDIS_HOST=localhost +REDIS_PORT=56379 + +# --- 임베딩 --- +# local : 로컬 multilingual-e5-small (384차원, 최초 1회 모델 다운로드 후 오프라인) +# mock : 문자 bigram 해싱 — 의미는 못 잡음 +# openai : text-embedding-3-small (dimensions=384 로 요청) +EMBEDDING_PROVIDER=local +EMBEDDING_LOCAL_MODEL=Xenova/multilingual-e5-small + +# --- LLM --- +# mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값) +# openai : 실제 OpenAI 호출 +LLM_PROVIDER=mock +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# --- 생성/중복제거 튜닝 --- +# 코사인 자동 병합 임계값. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본으로 나오므로 +# 0.92 는 오병합을 부른다. 실측상 어순 변형만 0.999 대에 모이므로 0.99 로 둔다. +DEDUP_COSINE_THRESHOLD=0.99 +# trigram 유사도 사전 필터 +DEDUP_TRIGRAM_THRESHOLD=0.6 +# 벡터 비교 대상 상위 후보 수 +DEDUP_CANDIDATE_LIMIT=20 +# 1회 생성 요청당 키워드 목표 개수 +GENERATION_TARGET_KEYWORDS=15 +# 주기 리프레시 간격(일) +REFRESH_INTERVAL_DAYS=30 diff --git a/ontology/.gitignore b/ontology/.gitignore new file mode 100644 index 0000000..318da79 --- /dev/null +++ b/ontology/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +*.tsbuildinfo +.DS_Store + +# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump) +data/*.sql.gz diff --git a/ontology/Dockerfile b/ontology/Dockerfile new file mode 100644 index 0000000..4a69726 --- /dev/null +++ b/ontology/Dockerfile @@ -0,0 +1,23 @@ +# o2o-site-ontology — 발행 사이트의 메타 키워드를 주는 서비스. +# ★ 왜 이 레포 안에 있나 (2026-09-14) — 발행 파이프라인이 이걸 부르는데 따로 띄워 두면 +# "코드는 올라갔는데 서버가 없어" 로 조용히 키워드 없이 발행된다. compose 한 벌로 같이 뜬다. +# ★ alpine 을 쓰지 않는다 (2026-09-14 실측). 임베딩 런타임(onnxruntime)이 musl 용 바이너리를 +# 내주지 않아 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다. +FROM node:22-slim + +WORKDIR /app + +# 의존성 먼저 — 소스만 바뀌면 이 층은 캐시된다. +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# 임베딩 모델(약 120MB)은 첫 실행에 받아 볼륨에 남긴다 — 이미지에 굽지 않는다. +ENV PORT=3100 \ + TRANSFORMERS_CACHE=/app/.cache \ + HF_HOME=/app/.cache + +EXPOSE 3100 +CMD ["node", "dist/main.js"] diff --git a/ontology/README.md b/ontology/README.md new file mode 100644 index 0000000..ee479e7 --- /dev/null +++ b/ontology/README.md @@ -0,0 +1,429 @@ +# o2o-site-ontology + +o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스. + +- **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건) +- 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색 +- 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔** +- 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만 + +## 빠른 시작 (로컬) + +**필요한 것:** Docker Desktop 실행 중 · Node 20+ + +```bash +git clone https://gitea.o2o.kr/Web4ai/o2o-site-ontology.git +cd o2o-site-ontology +npm install +npm run setup # .env 생성 → 컨테이너 → 마이그레이션 → 시드 → 키워드 7,093건 적재 +npm start # http://localhost:3100 +``` + +`npm run setup` 이 전부 한다. API 키는 필요 없다 (LLM=mock, 임베딩=로컬 모델). +최초 1회 임베딩 모델을 내려받는다 — 약 120MB, 1~2분. 그 뒤로는 오프라인으로 동작한다. + +포트는 기존 개발환경과 겹치지 않게 잡아 두었다 — postgres `55432`, redis `56379`, 앱 `3100`. + +확인: **http://localhost:3100/demo** 입력창에 `스테이 머뭄` + +엑셀 산출 스크립트를 쓸 때만 파이썬 의존성이 필요하다. + +```bash +pip3 install -r scripts/requirements.txt +``` + +
수동으로 단계별 실행 + +```bash +cp .env.example .env +npm run db:up # postgres(pgvector) + redis +npm run db:migrate +npm run db:seed # 업종/지역 계층 + 데모 업체 +npm run dataset:ingest # 군산 상세 974건 +npm run dataset:ingest-nationwide # 전국 54개 지역 +``` +
+ +브라우저에서 **http://localhost:3100/demo** 를 열면 매칭 콘솔이 뜬다. +입력창에 `스테이 머뭄` 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고 +적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다. + +`npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다. + +### 실제 OpenAI 로 전환 + +```bash +# .env +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4.1-mini +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +``` + +`LLM_PROVIDER=mock` 은 문자 bigram 해싱 임베딩을 쓴다. 랜덤이 아니라 **비슷한 문자열이면 +비슷한 벡터**가 나오므로 중복제거 파이프라인 검증에는 충분하지만, 의미 기반 중복 +(`강남 미용실` ↔ `강남 헤어샵`) 은 실제 임베딩 모델에서만 잡힌다. + +## 데이터 모델 + +| 테이블 | 역할 | +|---|---| +| `industry` / `region` | `ltree` 업종·지역 계층. 상위 노드 키워드 상속의 기반 | +| `merchant` | 업체. `external_id` 가 o2o-site-AEO 의 사이트 ID | +| `keyword` | **전역** 키워드 사전. `normalized` 유니크, `aliases[]`, `embedding vector(1536)` | +| `merchant_keyword` | 업체 ↔ 키워드 연결. `relevance` / `status` / `impressions` / `ctr` | +| `qa_pair` | AEO 용 질문-답변 쌍 | +| `generation_run` | 생성 감사 로그 (프롬프트 버전·토큰·통계) | + +키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만 +저장되고, `강남 미용실` 을 쓰는 업체가 100곳이어도 중복제거가 성립한다. + +## 시드 데이터 주의 + +`src/db/seed.ts` 의 업체 중 **스테이머뭄(site-3001)만 실재 업체**이고, 나머지(레브살롱·헤어랩·소담한상)는 +동작 확인용 가상 업체다. 스테이머뭄 프로필도 공개 정보로 확인된 항목만 채웠고, +가격·바베큐·스파·주차·애견동반은 `profile.unverified` 에 남겨 두었다 — 사업자 확인 후 채울 것. + +매칭 품질은 프로필 정확도에 그대로 좌우된다. 실제로 초기 시드에 잘못 들어가 있던 +"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고, +실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다. + +## 배포 + +DB 가 기준이다. 데이터셋 JSON 은 생성 원본일 뿐 적재분과 완전히 같지 않다 +(지역 간 중복 태그가 한 행으로 합쳐지므로 7,129 → 6,243). + +| 산출물 | 명령 | 용도 | +|---|---|---| +| `data/배포용_키워드_DB덤프.xlsx` | `npm run db:export-xlsx` | **DB 7개 테이블 전부**. 8시트 | +| `data/ontology-dump.sql.gz` | `npm run db:dump` | **임베딩 포함 그대로 복원**. 13MB, git 제외 | + +### A. pg_dump 복원 (권장) + +```bash +npm run db:dump +gunzip -c data/ontology-dump.sql.gz | psql "$TARGET_DATABASE_URL" +``` + +대상 DB 에 `vector` · `ltree` · `pg_trgm` 확장이 있어야 한다. 재임베딩이 없어 즉시 뜬다. +복원 검증 완료 — 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존, HNSW 인덱스 재생성, 벡터 검색 동작. + +### B. 재적재 + +```bash +npm run db:migrate && npm run db:seed +npm run dataset:ingest && npm run dataset:ingest-nationwide +``` + +텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초. +같은 모델이면 값이 동일하게 나오므로 A 와 결과가 같다. + +### 엑셀 시트 (DB 테이블과 1:1) + +| 시트 | 테이블 | 행 | +|---|---|---:| +| 키워드 | `keyword` | 7,093 | +| 지역 | `region` | 70 | +| 업종 | `industry` | 9 | +| 업체 | `merchant` | 4 | +| 업체키워드 | `merchant_keyword` | 11 | +| QA(AEO) | `qa_pair` | 20 | +| 생성이력 | `generation_run` | 4 | +| 배포가이드 | — | 25 | + +임베딩만 담지 않는다 (384 float × 7천 행). `[임베딩]` 열에 보유 여부만 표시하며, +같은 모델로 재생성하면 동일하게 복원된다. + +### 현재 적재 내용 + +| 출처 | 건수 | 내용 | +|---|---|---| +| `nationwide` | 6,243 | 전국 54개 지역 | +| `dataset` | 850 | 군산 상세 (매칭 엔진 개발용) | +| **합계** | **7,093** | 전부 임베딩 보유 | + +⚠ 검색량은 아직 비어 있다. 실서비스 전에 키워드도구로 채우고 월 10 미만을 걷어내야 한다. + +## 전국 지역별 데이터셋 (기획 변경분) + +`data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**. +`npm run dataset:nationwide` 로 재생성한다 (`data/regions.json` → JSON → 엑셀). + +시트 4개: `키워드` / `지역마스터` / `지역별요약` / `사용가이드` + +**조합 폭발을 하지 않았다.** 군산 단일 지역 974건을 54개에 곱하면 5만 건이 되는데, +단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다. 지역당 ~110건으로 눌렀다. + +**지역 성격이 시설 키워드를 결정한다.** `regions.json` 의 `type`(해변·산간·호수·강변·도심·섬·계곡)에 +따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 0건, 태안·거제에는 산뷰가 0건이다. + +**티어** — 주력 568 / 보조 3,816 / 롱테일 1,836 / 태그 918. +주력은 페이지당 1개만 쓰는 대표 키워드 후보다. + +⚠ **이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.** +엑셀의 `월간검색수`·`경쟁도` 열은 비워 두었다. 네이버 검색광고 키워드도구로 채운 뒤 +월 10 미만을 걷어내야 실제로 쓸 수 있다. + +## 데이터셋 (군산 단일 지역 · 매칭 엔진용) + +`data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건. +실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과 +숙박 시설 용어를 어휘로 두고, 한국 로컬 숙박 검색에서 실제로 쓰이는 패턴만 전개했다. + +| 카테고리 | 건수 | 예시 | +|---|---:|---| +| 롱테일 | 374 | 군산 커플 오션뷰 펜션 | +| 시설 | 104 | 군산 자쿠지 펜션 | +| 권역 | 99 | 선유도 독채펜션 | +| 동반자 | 98 | 군산 애견동반 펜션 | +| 시즌 | 72 | 군산 여름휴가 펜션 | +| 관광지 | 64 | 경암동 철길마을 근처 숙소 | +| 태그 | 63 | 오션뷰 · 불멍 · 애견운동장 | +| 코어 | 51 | 군산 펜션 추천 | +| 질문형 | 33 | 군산 펜션 바베큐 가능한가요 | +| 의도 | 13 | 군산 펜션 실시간예약 | + +적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**. + +`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다. +적재는 upsert 이고, **데이터셋에서 빠진 행은 같이 지운다** — 안 그러면 재빌드할 때마다 +이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다). + +| 명령 | 용도 | +|---|---| +| `npm run dataset:build` | 데이터셋 생성 | +| `npm run dataset:ingest` | 임베딩 + 적재 + 잔여 정리 | +| `npm run dataset:purge` | 큐레이션 외 출처(`llm` 등) 제거. `--apply` 로 실행 | +| `npm run dataset:import-related` | 검색광고 키워드도구 내려받기(CSV/JSON) 병합. `--apply` 로 실행 | + +`dataset:import-related` 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는 +계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아 +`data/related-keywords.sample.csv` 형식으로 두면 그대로 병합된다 — +나중에 API 를 붙여도 이 임포터를 재사용한다. + +## 임베딩 임계값 — 실측으로 정정한 부분 + +설계 초안의 코사인 자동 병합 임계값 0.92 는 **틀렸다.** 짧은 한글 키워드에서는 +같은 도메인이기만 하면 절대 코사인이 기본적으로 높게 나온다. + +| 쌍 | 실제 관계 | cos (e5-small) | +|---|---|---:| +| 군산 키즈룸 펜션 ↔ 군산 펜션 키즈룸 | 중복 (어순) | **0.9995** | +| 군산 애견동반 펜션 ↔ 군산 반려견 동반 펜션 | 중복 (동의어) | 0.9886 | +| 선유도 펜션 ↔ 선유도 팬션 | 중복 (오타) | 0.9585 | +| 군산 펜션 ↔ 군산 호텔 | **별개** | 0.9698 | +| 선유도 펜션 ↔ 새만금 펜션 | **별개** | 0.9356 | + +중복과 별개의 분포가 겹치므로 단일 임계값으로는 깨끗하게 못 가른다 +(`paraphrase-multilingual-MiniLM-L12-v2` 도 동일). + +그래서 정책을 이렇게 바꿨다. + +- **자동 병합의 주력은 어휘 단계(1~2)** — 공백/구두점 정규화와 `pg_trgm` 이 오타·표기 변형을 잡는다 +- **벡터 단계는 0.99 로 올려 잡는다** — 어순 변형처럼 확실한 것만 걸린다 +- **적재 시에는 벡터 병합을 아예 하지 않고 검토 목록만 출력한다** (`npm run dataset:ingest` 끝부분) + +## 중복제거 4단계 + +값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다. + +| 단계 | 방법 | 걸러내는 것 | +|---|---|---| +| 0 | 금칙어 필터 | `최고`, `1위`, `100%` 등 과장광고 | +| 1 | `normalized` 완전 일치 (공백·구두점 제거) | `강남 뿌리 염색` = `강남 뿌리염색` | +| 2 | `pg_trgm` 유사도 ≥ 0.6 | `강남 뿌리염색약` → `강남 뿌리염색` | +| 3 | 코사인 유사도 ≥ 0.92 | `강남 미용실` ↔ `강남 헤어샵` (의미 중복) | +| 4 | 신규 등록 | 위에 안 걸리면 새 키워드 | + +1~3 단계에서 매칭되면 원래 표기는 버리지 않고 기존 키워드의 `aliases[]` 로 흡수한다 +(롱테일 검색어 보존 + 성과 피드백 매칭에 사용). + +임계값은 `.env` 의 `DEDUP_COSINE_THRESHOLD` / `DEDUP_TRIGRAM_THRESHOLD` 로 조정. + +## API + +| 메서드 | 경로 | 용도 | +|---|---|---| +| `GET` | `/health` | 헬스체크 | +| `POST` | `/v1/merchants/publish` | **사이트 발행 웹훅** — 업체 upsert + 생성 예약 (`sync:true` 면 동기 실행) | +| `POST` | `/v1/merchants/:id/generate?sync=true` | 수동 재생성 | +| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 | +| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags | +| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints | +| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) | +| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) | +| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) | +| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 | + +`:id` 는 `external_id` 또는 내부 UUID 둘 다 받는다. + +### 발행 웹훅 예시 + +```bash +curl -X POST http://localhost:3100/v1/merchants/publish \ + -H 'content-type: application/json' \ + -d '{ + "externalId": "site-1003", + "name": "강남 뷰티랩", + "industryId": "beauty.hair", + "regionId": "kr.seoul.gangnam", + "description": "강남 미용실. 염색 전문.", + "profile": { "services": ["뿌리염색", "여성펌"], "features": ["주차가능"] } + }' +``` + +### 서빙 예시 + +```bash +curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8' +``` + +```json +{ + "title": "레브살롱 | 강남 미용실", + "description": "강남역 3번 출구 앞 프라이빗 헤어살롱. ... 정보를 확인하세요.", + "keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "..."], + "tags": [{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95, "aliases": ["강남미용실"] }] +} +``` + +### 매칭 — 속성별 다중 질의 + 사실 기반 필터 + +프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측: + +| 방식 | 점수 범위 | 폭 | +|---|---|---| +| 통짜 질의문 하나 | 0.8761 ~ 0.8837 | 0.0076 | +| 속성별로 쪼갠 질의 | 0.8552 ~ 0.9176 | **0.0624** | + +976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다. +그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다. + +| 레인 | 가중치 | 질의문 예시 | +|---|---|---| +| 유형 | 1.0 | `군산 펜션 독채 감성숙소` | +| 위치 | 0.7 | `원도심 신흥동 말랭이마을 동국사 근처` | +| 동반자 | 0.6 | `커플 친구 가족 혼자` | +| 시설 | 0.6 | `프라이빗` | + +레인 설계에서 실측으로 배운 것 세 가지. + +- **브랜드 레인을 두면 안 된다.** 상호는 사전에 없으므로 결국 `군산 펜션` 만 남아 + 가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 `~예약` 으로 도배됐다. +- **레인끼리 겹치면 안 된다.** 권역과 인근을 따로 두었더니 `신흥동` 토큰이 양쪽에 걸려 + 위치 키워드가 상위를 쓸어갔고, 정작 핵심인 `군산 펜션 독채` 가 8위로 밀렸다. 한 레인으로 합쳤다. +- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 + 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다. + +#### 레인 구성 + +| 레인 | 가중치 | 출처 | 비고 | +|---|---|---|---| +| 유형 | 1.0 | 지역 + 업종 + 숙소유형 | 앵커. 주력 키워드가 여기서 나온다 | +| **고객언어** | **0.9** | `reviewSignals` 빈출어 + `hashtags` | 사업자 표현보다 검색어에 가깝다 | +| 위치 | 0.7 | 권역 + 행정동 + 인근 랜드마크 | | +| 동반자 | 0.6 | `audiences` | | +| 시설 | 0.6 | 정규화된 `amenities` | | + +레인 텍스트는 **낱말 단위로 중복을 제거**한다. 문자열 단위 Set 만으로는 `신흥동` 과 +`신흥동 일본식가옥` 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다. + +후보 풀은 **업체 업종으로 한정**하고 `source IN ('dataset','manual')` 만 본다. +사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다. + +#### 고객 언어 신호 + +리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다. + +```json +"hashtags": ["#군산감성숙소", "#뚜벅이여행"], +"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }] +``` + +빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다. +현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다. + +#### 사실 기반 필터 — 벡터가 못 거르는 것 + +임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다. + +| 규칙 | 예시 | +|---|---| +| 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 | +| 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 | +| 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 | +| **미확인 시설** | `바베큐` 가 `unverified` → 배제하지 않고 **보류** 표시 | + +마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다. +스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다. + +#### 레인별 출력 = SEO 페이지 배분 + +응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 — +한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다. + +| 레인 | → 페이지 | 주력 | +|---|---|---| +| 유형 | 메인 | 군산 펜션 독채 | +| 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 | +| 동반자 | 객실 | 군산 커플 프라이빗 펜션 | +| 시설 | 시설 | 군산 프라이빗 펜션 | + +### 매칭 예시 + +```bash +curl -s -X POST http://localhost:3100/v1/match \ + -H 'content-type: application/json' -d '{"query":"스테이 머뭄","limit":5}' +``` + +업체명이면 상호만으로 임베딩하지 않고 **프로필 전체를 질의문으로 조립**한다. +상호는 브랜드명이라 그것만으로는 매칭이 얕아지기 때문이다. + +``` +해석: 스테이머뭄 (군산 / 펜션) ← "스테이 머뭄" 과 띄어쓰기가 달라도 해석됨 +질의문: 스테이머뭄 군산 펜션 고군산군도 초입에 자리한 독채 펜션 … 오션뷰 애견동반 … + +0.8765 군산 독채펜션 예약 [transactional] 코어 +0.8762 군산 애견동반 독채펜션 [local] 동반자 +0.8751 고군산군도 독채펜션 [local] 권역 +0.8735 군산 바베큐 펜션 예약 [transactional] 시설 +0.8718 군산 오션뷰 펜션 예약 [transactional] 시설 +``` + +업체가 해석되지 않으면 입력 문장을 그대로 질의로 쓴다. + +``` +"선유도 근처에서 바베큐 되는 독채" + 0.9166 선유도 독채펜션 + 0.9155 선유도 바베큐 펜션 + 0.9051 선유도해수욕장 근처 숙소 +``` + +## 생성 주기 + +- **발행 즉시** — `/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창) +- **주기 리프레시** — 매일 03:00 크론이 `REFRESH_INTERVAL_DAYS`(기본 30일) 지난 업체를 적재 +- **성과 기반** — 노출 100회 이상 & CTR < 0.2% 인 키워드는 `demoted` 로 강등, 다음 사이클에서 대체 + +프롬프트에는 해당 업체와 같은 업종의 기존 키워드 목록을 넣어 **중복 후보 생성 자체를 줄인다.** +그래도 남는 중복만 위 4단계가 처리한다. + +## 남은 작업 + +- [ ] JSON-LD (`LocalBusiness` / `FAQPage` / `Service`) 조립 — `structuredDataHints` 를 그대로 매핑 +- [ ] `/llms.txt` 서빙 +- [ ] 업종 `ltree` 상위 노드 키워드 상속 (`source: 'inherited'`) +- [ ] Redis 응답 캐시 (서빙은 읽기 99%) +- [ ] Search Console API 연동 (현재는 `/performance` 수동 주입) +- [ ] 어드민 UI + +## 아키텍처 도식 + +| 파일 | 용도 | +|---|---| +| `docs/architecture.html` | 브라우저용 설계 문서 — 전체 흐름 · 중복제거 단계 · 데이터 모델 | +| `docs/architecture.pptx` | 발표용 11장 덱. 도식은 이미지가 아니라 네이티브 도형이라 PowerPoint 에서 바로 편집된다 | + +덱은 `python3 scripts/build-deck.py` 로 다시 생성한다 (`pip install python-pptx` 필요). +한글 폰트는 `Apple SD Gothic Neo`, 코드는 `Menlo` 로 지정되어 있다 — Windows 에서 열 때는 +`scripts/build-deck.py` 상단의 `SANS` / `MONO` 를 `맑은 고딕` / `Consolas` 로 바꿔 다시 생성하면 된다. diff --git a/ontology/data/gunsan-pension-keywords.json b/ontology/data/gunsan-pension-keywords.json new file mode 100644 index 0000000..5b4f956 --- /dev/null +++ b/ontology/data/gunsan-pension-keywords.json @@ -0,0 +1,7008 @@ +{ + "topic": "군산 펜션", + "locale": "ko-KR", + "generatedBy": "hand-authored vocabulary × search-pattern expansion", + "count": 1000, + "items": [ + { + "keyword": "군산 펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 감성펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.97 + }, + { + "keyword": "군산 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 숙소 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 숙소 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 숙소 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 숙소 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 숙소 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 풀빌라 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 풀빌라 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 풀빌라 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 풀빌라 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 풀빌라 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 독채펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 독채펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 독채펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 독채펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 독채펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 스파펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 스파펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 스파펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 스파펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 스파펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 애견펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 애견펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 애견펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 애견펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 애견펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 감성펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 감성펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 감성펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 감성펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 감성펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "코어", + "relevance": 0.93 + }, + { + "keyword": "군산 글램핑", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 카라반", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 캠핑장", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 게스트하우스", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 민박", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 리조트", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 한옥펜션", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 촌집", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 별장", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 독채스테이", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 감성숙소", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 스테이", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 일본식가옥 숙소", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.86 + }, + { + "keyword": "군산 펜션 저렴한곳", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 가성비", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 최저가", + "intent": "transactional", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 실시간예약", + "intent": "transactional", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 당일예약", + "intent": "transactional", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 특가", + "intent": "transactional", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 할인", + "intent": "transactional", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 위치", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 주차", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 전화번호", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 체크인시간", + "intent": "informational", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 조식포함", + "intent": "local", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 펜션 청소상태", + "intent": "informational", + "kind": "keyword", + "category": "의도", + "relevance": 0.88 + }, + { + "keyword": "군산 독채", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, + { + "keyword": "군산 한옥", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, + { + "keyword": "군산 숙박", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, + { + "keyword": "선유도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "선유도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "선유도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "선유도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "무녀도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "무녀도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "무녀도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "무녀도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "장자도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "장자도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "장자도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "장자도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "대장도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "대장도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "대장도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "대장도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "신시도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "신시도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "신시도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "신시도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "야미도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "야미도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "야미도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "야미도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "고군산군도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "고군산군도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "고군산군도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "고군산군도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.9 + }, + { + "keyword": "새만금 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "새만금 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "새만금 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "새만금 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "비응항 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "비응항 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "비응항 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "비응항 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "오식도 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "오식도 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "오식도 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "오식도 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "은파호수공원 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "은파호수공원 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "은파호수공원 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "은파호수공원 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "월명동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "월명동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "월명동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "월명동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "나운동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "나운동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "나운동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "나운동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "수송동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "수송동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "수송동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "수송동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "미룡동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "미룡동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "미룡동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "미룡동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "옥도면 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "옥도면 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "옥도면 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "옥도면 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "군산 원도심 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "군산 원도심 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "군산 원도심 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "군산 원도심 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "신흥동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "신흥동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "신흥동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "신흥동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "영화동 펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "영화동 숙소", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "영화동 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "영화동 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.85 + }, + { + "keyword": "선유도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "선유도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "선유도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "선유도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "선유도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "무녀도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "무녀도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "무녀도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "무녀도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "무녀도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "장자도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "장자도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "장자도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "장자도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "장자도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "대장도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "대장도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "대장도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "대장도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "대장도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "신시도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "신시도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "신시도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "신시도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "신시도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "야미도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "야미도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "야미도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "야미도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "야미도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "고군산군도 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "고군산군도 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "고군산군도 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "고군산군도 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "고군산군도 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "권역", + "relevance": 0.82 + }, + { + "keyword": "군산 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.91 + }, + { + "keyword": "군산 커플 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 커플 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 커플 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 커플 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 커플 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 커플 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 커플 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 커플 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 커플 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.91 + }, + { + "keyword": "군산 가족 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 가족 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 가족 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 가족 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 가족 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 가족 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 가족 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 가족 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 가족 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.91 + }, + { + "keyword": "군산 친구 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 친구 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 친구 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 친구 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 친구 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 친구 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 친구 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 친구 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 친구 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.91 + }, + { + "keyword": "군산 애견동반 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 애견동반 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 애견동반 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 애견동반 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 애견동반 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 애견동반 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 애견동반 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 애견동반 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 애견동반 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.91 + }, + { + "keyword": "군산 단체 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 단체 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 단체 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 단체 펜션 후기", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 단체 펜션 순위", + "intent": "informational", + "kind": "keyword", + "category": "동반자", + "relevance": 0.8 + }, + { + "keyword": "군산 단체 풀빌라", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 단체 독채펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 단체 스파펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 단체 애견펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.78 + }, + { + "keyword": "군산 신혼 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 신혼 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 신혼 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 아이동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 아이동반 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 아이동반 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 유아동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 유아동반 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 유아동반 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 부모님 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 부모님 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 부모님 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 효도여행 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 효도여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 효도여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 대학생 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 대학생 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 대학생 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 MT 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 MT 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 MT 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 워크샵 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 워크샵 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 워크샵 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 회사 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 회사 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 회사 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 태교여행 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 태교여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 태교여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 혼자 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 혼자 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 혼자 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 여자끼리 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 여자끼리 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 여자끼리 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 2인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 2인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 2인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 3인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 3인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 3인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 4인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 4인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 4인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 6인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 6인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 6인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 10인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 10인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 10인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 20인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 20인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 20인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 오션뷰", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 오션뷰 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 오션뷰 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 오션뷰 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 바다뷰", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 바다뷰 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바다뷰 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바다뷰 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 독채", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 독채 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 독채 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 독채 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 프라이빗", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 프라이빗 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 프라이빗 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 프라이빗 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 스파", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 스파 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 스파 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 스파 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 자쿠지", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 자쿠지 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 자쿠지 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 자쿠지 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 바베큐", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 바베큐 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바베큐 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 바베큐 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.89 + }, + { + "keyword": "군산 펜션 수영장", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.76 + }, + { + "keyword": "군산 수영장 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 수영장 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 수영장 펜션 가격", + "intent": "transactional", + "kind": "keyword", + "category": "시설", + "relevance": 0.72 + }, + { + "keyword": "군산 노을뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 노을뷰", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 일출뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 일출뷰", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 월풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 월풀", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 온수풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 온수풀", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 야외수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 야외수영장", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 인피니티풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 인피니티풀", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 불멍 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 불멍", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 화로대 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 화로대", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 넷플릭스 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 넷플릭스", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 빔프로젝터 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 빔프로젝터", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 노래방 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 노래방", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 파티룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 파티룸", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 복층 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 복층", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 테라스 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 테라스", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 마당 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 마당", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 벽난로 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 벽난로", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 애견운동장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 애견운동장", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 키즈룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 키즈룸", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 트램폴린 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 트램폴린", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 무료주차 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 무료주차", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 조식 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 조식", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "군산 세미나실 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.79 + }, + { + "keyword": "군산 펜션 세미나실", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.7 + }, + { + "keyword": "선유도 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "선유도 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "무녀도 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "장자도 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "대장도 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "시설", + "relevance": 0.74 + }, + { + "keyword": "군산 여름휴가 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 여름휴가 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "여름휴가 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 물놀이 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 물놀이 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "물놀이 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 해수욕 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 해수욕 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "해수욕 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 겨울 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 겨울 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "겨울 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 연말 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 연말 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "연말 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 크리스마스 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 크리스마스 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "크리스마스 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 신정 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 신정 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "신정 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 설날 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 설날 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "설날 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 추석 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 추석 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "추석 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 봄 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 봄 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "봄 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 벚꽃 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 벚꽃 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "벚꽃 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 가을 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 가을 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "가을 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 단풍 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 단풍 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "단풍 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 일출 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 일출 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "일출 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 낙조 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 낙조 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "낙조 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 불꽃놀이 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 불꽃놀이 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "불꽃놀이 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 성수기 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 성수기 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "성수기 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 비수기 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 비수기 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "비수기 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 주말 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 주말 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "주말 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 평일 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.8 + }, + { + "keyword": "군산 평일 펜션 예약", + "intent": "transactional", + "kind": "keyword", + "category": "시즌", + "relevance": 0.71 + }, + { + "keyword": "평일 군산 숙소", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.68 + }, + { + "keyword": "군산 생일 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 생일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 기념일 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 기념일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 결혼기념일 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 결혼기념일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 프러포즈 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 프러포즈 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 100일 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 100일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 가족여행 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 가족여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 단합대회 펜션", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.75 + }, + { + "keyword": "군산 단합대회 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "시즌", + "relevance": 0.69 + }, + { + "keyword": "군산 감성 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 감성 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 조용한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 조용한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 분위기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 분위기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 예쁜 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 예쁜 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 사진찍기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 사진찍기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 인생샷 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 인생샷 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 뷰맛집 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 뷰맛집 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 뷰맛집 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 깔끔한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 깔끔한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 깔끔한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 신축 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 신축 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 신축 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 프라이빗한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 프라이빗한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 프라이빗한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 혼자 있기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 혼자 있기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 혼자 있기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 1박2일 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 1박2일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 2박3일 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 2박3일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 당일치기 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 당일치기 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 주말여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 주말여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 뚜벅이 여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 뚜벅이 여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 혼행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 혼행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 워케이션 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 워케이션 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 무박 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 무박 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 한달살기 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 한달살기 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 커플 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "선유도해수욕장 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "선유도해수욕장 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "선유도해수욕장 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "선유도해수욕장 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "새만금방조제 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "새만금방조제 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "새만금방조제 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "새만금방조제 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "경암동 철길마을 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "경암동 철길마을 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "경암동 철길마을 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "경암동 철길마을 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "근대역사박물관 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "근대역사박물관 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "근대역사박물관 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "근대역사박물관 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "초원사진관 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "초원사진관 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "초원사진관 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "초원사진관 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "이성당 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "이성당 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "이성당 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "이성당 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "동국사 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "동국사 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "동국사 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "동국사 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "진포해양테마공원 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "진포해양테마공원 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "진포해양테마공원 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "진포해양테마공원 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "신흥동 일본식가옥 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "신흥동 일본식가옥 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "신흥동 일본식가옥 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "신흥동 일본식가옥 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "은파호수공원 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "은파호수공원 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "은파호수공원 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "은파호수공원 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "월명공원 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "월명공원 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "월명공원 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "월명공원 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "금강하구둑 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "금강하구둑 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "금강하구둑 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "금강하구둑 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "철새조망대 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "철새조망대 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "철새조망대 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "철새조망대 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "채만식문학관 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "채만식문학관 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "채만식문학관 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "채만식문학관 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "째보선창 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "째보선창 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "째보선창 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "째보선창 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "시간여행마을 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "시간여행마을 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "시간여행마을 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "시간여행마을 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "말랭이마을 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "말랭이마을 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "말랭이마을 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "말랭이마을 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "군산 근대문화역사거리 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "군산 근대문화역사거리 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "군산 근대문화역사거리 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "군산 근대문화역사거리 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "해망굴 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "해망굴 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "해망굴 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "해망굴 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "군산항 뜬다리부두 근처 펜션", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.83 + }, + { + "keyword": "군산항 뜬다리부두 근처 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.8 + }, + { + "keyword": "군산항 뜬다리부두 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.72 + }, + { + "keyword": "군산항 뜬다리부두 가까운 숙소", + "intent": "local", + "kind": "keyword", + "category": "관광지", + "relevance": 0.66 + }, + { + "keyword": "오션뷰", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "바다뷰", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "독채", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "프라이빗", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "스파", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "자쿠지", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "바베큐", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "수영장", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "노을뷰", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "일출뷰", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "월풀", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "온수풀", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "야외수영장", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "인피니티풀", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "불멍", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "화로대", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "넷플릭스", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "빔프로젝터", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "노래방", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "파티룸", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "복층", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "테라스", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "마당", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "벽난로", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "애견운동장", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "키즈룸", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "트램폴린", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "무료주차", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "조식", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "세미나실", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "커플", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "가족", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "친구", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "애견동반", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "단체", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "숙소", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "풀빌라", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "독채펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "스파펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "애견펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "감성펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "글램핑", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "카라반", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "캠핑장", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "게스트하우스", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "민박", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "리조트", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "한옥펜션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "촌집", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "별장", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "독채스테이", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "감성숙소", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "스테이", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "일본식가옥 숙소", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "오션뷰객실", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "반려동물동반", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "금연객실", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "엘리베이터", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "와이파이", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "취사가능", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "단체가능", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "조용한", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "신축", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "리모델링", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "뷰맛집", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "인생샷", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "감성", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "분위기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "예쁜", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "사진찍기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "깔끔한", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "프라이빗한", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "혼자 있기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "1박2일", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "2박3일", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "당일치기", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "주말여행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "뚜벅이 여행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "혼행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "워케이션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "무박", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "한달살기", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "2인전용", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "소인원", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "뚜벅이", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "원도심", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "군산 커플 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 가족 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 친구 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 애견동반 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 단체 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 아이 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 부모님 펜션 어디가 좋아요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산에 오션뷰 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 바다뷰 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 독채 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 프라이빗 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 스파 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 자쿠지 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 바베큐 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "군산에 수영장 펜션 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.67 + }, + { + "keyword": "선유도 펜션 어떻게 가나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.64 + }, + { + "keyword": "선유도 숙소 예약 언제 해야 하나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.6 + }, + { + "keyword": "무녀도 펜션 어떻게 가나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.64 + }, + { + "keyword": "무녀도 숙소 예약 언제 해야 하나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.6 + }, + { + "keyword": "장자도 펜션 어떻게 가나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.64 + }, + { + "keyword": "장자도 숙소 예약 언제 해야 하나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.6 + }, + { + "keyword": "대장도 펜션 어떻게 가나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.64 + }, + { + "keyword": "대장도 숙소 예약 언제 해야 하나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.6 + }, + { + "keyword": "신시도 펜션 어떻게 가나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.64 + }, + { + "keyword": "신시도 숙소 예약 언제 해야 하나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.6 + }, + { + "keyword": "군산 펜션 1박 얼마인가요", + "intent": "transactional", + "kind": "keyword", + "category": "질문형", + "relevance": 0.72 + }, + { + "keyword": "군산 펜션 바베큐 가능한가요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.7 + }, + { + "keyword": "군산 펜션 체크인 몇시인가요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.66 + }, + { + "keyword": "군산 펜션 주차 되나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.66 + }, + { + "keyword": "군산 애견동반 펜션 추가요금 있나요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.63 + }, + { + "keyword": "군산 펜션 성수기 언제인가요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.61 + }, + { + "keyword": "선유도 들어가는 배 시간표", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.55 + }, + { + "keyword": "군산 여행 몇박이 좋을까요", + "intent": "informational", + "kind": "keyword", + "category": "질문형", + "relevance": 0.54 + }, + { + "keyword": "군산 커플 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 커플 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 가족 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 친구 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 애견동반 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 오션뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 바다뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 독채 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 프라이빗 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 스파 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 자쿠지 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 바베큐 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "군산 단체 수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.5 + }, + { + "keyword": "선유도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "선유도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "선유도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "선유도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "선유도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "무녀도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "무녀도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "무녀도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "무녀도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "무녀도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "장자도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "장자도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "장자도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "장자도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "장자도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "대장도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "대장도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "대장도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "대장도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "대장도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "신시도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "신시도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "신시도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "신시도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "신시도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "야미도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "야미도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "야미도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "야미도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "야미도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "고군산군도 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "고군산군도 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "고군산군도 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "고군산군도 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "고군산군도 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.48 + }, + { + "keyword": "군산 여름휴가 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 여름휴가 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 여름휴가 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 여름휴가 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 여름휴가 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 물놀이 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 물놀이 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 물놀이 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 물놀이 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 물놀이 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 해수욕 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 해수욕 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 해수욕 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 해수욕 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 해수욕 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 겨울 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 겨울 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 겨울 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 겨울 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 겨울 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 연말 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 연말 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 연말 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 연말 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 연말 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 크리스마스 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 크리스마스 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 크리스마스 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 크리스마스 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 크리스마스 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 신정 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 신정 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 신정 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 신정 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 신정 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 설날 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 설날 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 설날 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 설날 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 설날 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 추석 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 추석 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 추석 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 추석 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 추석 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 봄 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 봄 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 봄 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 봄 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 봄 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 벚꽃 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 벚꽃 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 벚꽃 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 벚꽃 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 벚꽃 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 가을 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 가을 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 가을 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 가을 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 가을 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 단풍 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 단풍 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 단풍 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 단풍 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 단풍 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 일출 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 일출 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 일출 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 일출 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 일출 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 낙조 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 낙조 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 낙조 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 낙조 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 낙조 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 불꽃놀이 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 불꽃놀이 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 불꽃놀이 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 불꽃놀이 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 불꽃놀이 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 성수기 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 성수기 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 성수기 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 성수기 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 성수기 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 비수기 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 비수기 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 비수기 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 비수기 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 비수기 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 주말 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 주말 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 주말 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 주말 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 주말 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 평일 커플 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 평일 가족 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 평일 친구 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 평일 애견동반 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 평일 단체 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.44 + }, + { + "keyword": "군산 오션뷰 노을뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 일출뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 월풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 온수풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 야외수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 인피니티풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 불멍 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 화로대 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 넷플릭스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 빔프로젝터 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 노래방 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 파티룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 복층 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 테라스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 마당 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 벽난로 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 애견운동장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 키즈룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 트램폴린 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 무료주차 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 조식 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 오션뷰 세미나실 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 노을뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 일출뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 월풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 온수풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 야외수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 인피니티풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 불멍 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 화로대 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 넷플릭스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 빔프로젝터 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 노래방 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 파티룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 복층 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 테라스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 마당 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 벽난로 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 애견운동장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 키즈룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 트램폴린 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 무료주차 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 조식 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 바다뷰 세미나실 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 노을뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 일출뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 월풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 온수풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 야외수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 인피니티풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 불멍 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 화로대 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 넷플릭스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 빔프로젝터 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 노래방 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 파티룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 복층 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 테라스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 마당 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 벽난로 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 애견운동장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 키즈룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 트램폴린 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 무료주차 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 조식 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 독채 세미나실 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 노을뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 일출뷰 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 월풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 온수풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 야외수영장 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 인피니티풀 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 불멍 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 화로대 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 넷플릭스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 빔프로젝터 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 노래방 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 파티룸 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 복층 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + }, + { + "keyword": "군산 프라이빗 테라스 펜션", + "intent": "local", + "kind": "keyword", + "category": "롱테일", + "relevance": 0.4 + } + ] +} diff --git a/ontology/data/nationwide-pension-keywords.json b/ontology/data/nationwide-pension-keywords.json new file mode 100644 index 0000000..deaf5f2 --- /dev/null +++ b/ontology/data/nationwide-pension-keywords.json @@ -0,0 +1,85557 @@ +{ + "topic": "전국 지역별 펜션", + "locale": "ko-KR", + "generatedBy": "region master × search-pattern expansion (region-type aware)", + "regionCount": 54, + "count": 7129, + "items": [ + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "남이섬 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "남이섬 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "쁘띠프랑스 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "쁘띠프랑스 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "아침고요수목원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "아침고요수목원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "자라섬 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "자라섬 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "청평호 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "청평호 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가평 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "가평", + "regionKey": "kr.gyeonggi.gapyeong", + "regionType": "호수·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "두물머리 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "두물머리 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "세미원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "세미원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "용문사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "용문사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "양평 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "양평", + "regionKey": "kr.gyeonggi.yangpyeong", + "regionType": "강변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "산정호수 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "산정호수 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천아트밸리 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천아트밸리 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "허브아일랜드 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "허브아일랜드 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "포천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "포천", + "regionKey": "kr.gyeonggi.pocheon", + "regionType": "호수·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 커플 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "헤이리예술마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "헤이리예술마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "임진각 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "임진각 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "프로방스마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "프로방스마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "파주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "파주", + "regionKey": "kr.gyeonggi.paju", + "regionType": "도심·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "마니산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "마니산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "동막해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "동막해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화고인돌 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화고인돌 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "전등사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "전등사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "강화 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "강화", + "regionKey": "kr.incheon.ganghwa", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.94 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "영종도 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "무의도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "무의도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "하나개해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "하나개해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "을왕리 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "인천", + "region": "을왕리", + "regionKey": "kr.incheon.yeongjong", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "소양강스카이워크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "소양강스카이워크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "김유정역 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "김유정역 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "의암호 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "의암호 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "남이섬 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "남이섬 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "춘천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "춘천", + "regionKey": "kr.gangwon.chuncheon", + "regionType": "호수·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 스키 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 스키장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 보드 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "비발디파크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "비발디파크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "은행나무숲 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "은행나무숲 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천강 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천강 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "홍천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "홍천", + "regionKey": "kr.gangwon.hongcheon", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "자작나무숲 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "자작나무숲 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "내린천 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "내린천 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "백담사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "백담사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "인제 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "인제", + "regionKey": "kr.gangwon.inje", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 스키 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 스키장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 보드 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "대관령 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "대관령 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "오대산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "오대산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "월정사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "월정사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "알펜시아 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "알펜시아 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "양떼목장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "양떼목장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "평창 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "평창", + "regionKey": "kr.gangwon.pyeongchang", + "regionType": "산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 스키 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 스키장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 보드 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "하이원리조트 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "하이원리조트 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "레일바이크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "레일바이크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "병방치스카이워크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "병방치스카이워크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "정선 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "정선", + "regionKey": "kr.gangwon.jeongseon", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "경포대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "경포대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "안목해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "안목해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "정동진 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "정동진 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "주문진 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "주문진 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "오죽헌 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "오죽헌 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "강릉 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "강릉", + "regionKey": "kr.gangwon.gangneung", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "설악산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "설악산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "대포항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "대포항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "영금정 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "영금정 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "아바이마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "아바이마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "속초 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "속초", + "regionKey": "kr.gangwon.sokcho", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "낙산사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "낙산사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "죽도해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "죽도해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "하조대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "하조대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "서피비치 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "서피비치 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "인구해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "인구해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "양양 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "양양", + "regionKey": "kr.gangwon.yangyang", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "송지호 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "송지호 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "화진포 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "화진포 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "통일전망대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "통일전망대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "백섬해상전망대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "백섬해상전망대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "고성 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "고성", + "regionKey": "kr.gangwon.goseong", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "추암촛대바위 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "추암촛대바위 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "망상해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "망상해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "무릉계곡 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "무릉계곡 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "동해 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "동해", + "regionKey": "kr.gangwon.donghae", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "장호항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "장호항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "환선굴 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "환선굴 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "맹방해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "맹방해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "삼척 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "삼척", + "regionKey": "kr.gangwon.samcheok", + "regionType": "해변·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 스키 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 스키장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 보드 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "검룡소 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "검룡소 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "365세이프타운 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "365세이프타운 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "태백 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "강원", + "region": "태백", + "regionKey": "kr.gangwon.taebaek", + "regionType": "산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "도담삼봉 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "도담삼봉 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "만천하스카이워크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "만천하스카이워크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "고수동굴 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "고수동굴 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양강잔도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양강잔도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단양 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "단양", + "regionKey": "kr.chungbuk.danyang", + "regionType": "산간·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "청풍호 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "청풍호 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "의림지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "의림지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "배론성지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "배론성지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "제천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "제천", + "regionKey": "kr.chungbuk.jecheon", + "regionType": "호수·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "탄금대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "탄금대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "수안보온천 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "수안보온천 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주호 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주호 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "충주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "충주", + "regionKey": "kr.chungbuk.chungju", + "regionType": "호수·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "산막이옛길 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "산막이옛길 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "화양구곡 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "화양구곡 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "괴산 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충북", + "region": "괴산", + "regionKey": "kr.chungbuk.goesan", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "만리포해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "만리포해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "꽃지해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "꽃지해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "안면도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "안면도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "신두리해안사구 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "신두리해안사구 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "청포대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "청포대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "태안 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "태안", + "regionKey": "kr.chungnam.taean", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.94 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "보령 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "무창포 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "무창포 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "죽도상화원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "죽도상화원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "성주산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "성주산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "대천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "대천", + "regionKey": "kr.chungnam.boryeong", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "해미읍성 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "해미읍성 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "간월암 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "간월암 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "개심사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "개심사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "서산 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "서산", + "regionKey": "kr.chungnam.seosan", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 커플 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공산성 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공산성 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "무령왕릉 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "무령왕릉 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "마곡사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "마곡사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "공주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "공주", + "regionKey": "kr.chungnam.gongju", + "regionType": "도심·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 커플 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "궁남지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "궁남지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부소산성 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부소산성 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "백제문화단지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "백제문화단지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "부여 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "충남", + "region": "부여", + "regionKey": "kr.chungnam.buyeo", + "regionType": "도심·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "선유도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "선유도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "고군산군도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "고군산군도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "말랭이마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "말랭이마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "경암동 철길마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "경암동 철길마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "근대역사박물관 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "근대역사박물관 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "은파호수공원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "은파호수공원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "새만금 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "새만금 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "군산 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "군산", + "regionKey": "kr.jeonbuk.gunsan", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.94 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "부안 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "채석강 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "채석강 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "내소사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "내소사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "격포항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "격포항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "변산 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "변산", + "regionKey": "kr.jeonbuk.buan", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 커플 스파 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주한옥마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주한옥마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "경기전 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "경기전 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전동성당 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전동성당 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "남부시장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "남부시장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "전주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "자쿠지", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "수영장", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "전주", + "regionKey": "kr.jeonbuk.jeonju", + "regionType": "도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 스키 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 스키장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 보드 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "덕유산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "덕유산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주리조트 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주리조트 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "반디랜드 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "반디랜드 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "구천동계곡 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "구천동계곡 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "무주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "무주", + "regionKey": "kr.jeonbuk.muju", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "광한루원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "광한루원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "지리산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "지리산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "춘향테마파크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "춘향테마파크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "남원 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전북", + "region": "남원", + "regionKey": "kr.jeonbuk.namwon", + "regionType": "도심·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "오동도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "오동도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "돌산대교 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "돌산대교 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "향일암 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "향일암 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수해상케이블카 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수해상케이블카 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "낭만포차 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "낭만포차 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "여수 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "여수", + "regionKey": "kr.jeonnam.yeosu", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 자연 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 산책 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 자연 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 커플 산책 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 가을 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 갈대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천만습지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천만습지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "낙안읍성 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "낙안읍성 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천만국가정원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천만국가정원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "순천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "자연", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "산책", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "순천", + "regionKey": "kr.jeonnam.suncheon", + "regionType": "도심·습지", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 커플 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "죽녹원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "죽녹원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "메타세쿼이아길 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "메타세쿼이아길 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "소쇄원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "소쇄원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "담양 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "계곡뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "담양", + "regionKey": "kr.jeonnam.damyang", + "regionType": "도심·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "지리산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "지리산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "화엄사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "화엄사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "섬진강 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "섬진강 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "사성암 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "사성암 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "구례 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "구례", + "regionKey": "kr.jeonnam.gurye", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "땅끝마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "땅끝마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "두륜산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "두륜산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "대흥사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "대흥사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해남 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "해남", + "regionKey": "kr.jeonnam.haenam", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 배타고 가는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 섬 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "청산도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "청산도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "명사십리해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "명사십리해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도타워 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도타워 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "완도 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "배타고 가는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "섬", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "완도", + "regionKey": "kr.jeonnam.wando", + "regionType": "해변·섬", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성녹차밭 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성녹차밭 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "율포해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "율포해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "보성 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "전남", + "region": "보성", + "regionKey": "kr.jeonnam.boseong", + "regionType": "도심·해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 호수뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 커플 레이크뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "불국사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "불국사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "첨성대 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "첨성대 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "황리단길 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "황리단길 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "보문단지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "보문단지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "동궁과월지 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "동궁과월지 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "경주 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "호수뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "레이크뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "경주", + "regionKey": "kr.gyeongbuk.gyeongju", + "regionType": "도심·호수", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "호미곶 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "호미곶 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "영일대해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "영일대해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "죽도시장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "죽도시장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "스페이스워크 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "스페이스워크 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "포항 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "포항", + "regionKey": "kr.gyeongbuk.pohang", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 커플 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "하회마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "하회마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "월영교 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "월영교 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "도산서원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "도산서원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "안동 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "주차", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "도보 여행", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "안동", + "regionKey": "kr.gyeongbuk.andong", + "regionType": "도심·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "강구항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "강구항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "블루로드 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "블루로드 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "고래불해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "고래불해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "영덕 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "영덕", + "regionKey": "kr.gyeongbuk.yeongdeok", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "죽변항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "죽변항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "덕구온천 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "덕구온천 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "성류굴 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "성류굴 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "후포항 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "후포항 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "울진 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "울진", + "regionKey": "kr.gyeongbuk.uljin", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경새재 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경새재 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "단산모노레일 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "단산모노레일 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "에코랄라 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "에코랄라 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "문경 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경북", + "region": "문경", + "regionKey": "kr.gyeongbuk.mungyeong", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 배타고 가는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 섬 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "외도보타니아 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "외도보타니아 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "바람의언덕 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "바람의언덕 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "학동몽돌해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "학동몽돌해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "매미성 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "매미성 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "windy hill 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "windy hill 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "거제 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "배타고 가는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "섬", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "거제", + "regionKey": "kr.gyeongnam.geoje", + "regionType": "해변·섬", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 배타고 가는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 섬 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "동피랑벽화마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "동피랑벽화마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영케이블카 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영케이블카 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "미륵산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "미륵산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "한산도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "한산도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "장사도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "장사도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "통영 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "배타고 가는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "섬", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "통영", + "regionKey": "kr.gyeongnam.tongyeong", + "regionType": "해변·섬", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 배타고 가는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 섬 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "다랭이마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "다랭이마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "독일마을 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "독일마을 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "상주은모래비치 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "상주은모래비치 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "보리암 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "보리암 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "남해 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "배타고 가는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "섬", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "남해", + "regionKey": "kr.gyeongnam.namhae", + "regionType": "해변·섬", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 강뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 리버뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "화개장터 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "화개장터 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "쌍계사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "쌍계사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "섬진강 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "섬진강 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "최참판댁 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "최참판댁 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "하동 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "강뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "리버뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "하동", + "regionKey": "kr.gyeongnam.hadong", + "regionType": "산간·강변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "삼천포대교 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "삼천포대교 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천케이블카 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천케이블카 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "실안노을길 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "실안노을길 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사천 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "사천", + "regionKey": "kr.gyeongnam.sacheon", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 계곡 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 계곡뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 커플 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "지리산 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "지리산 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "동의보감촌 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "동의보감촌 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "대원사계곡 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "대원사계곡 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산청 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "불멍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "화로대", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "벽난로", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "단풍", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "계곡", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "물놀이", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경남", + "region": "산청", + "regionKey": "kr.gyeongnam.sancheong", + "regionType": "산간·계곡", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "해동용궁사 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "해동용궁사 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "일광해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "일광해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "아난티코브 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "아난티코브 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "죽성성당 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "죽성성당 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "기장 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "바베큐", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "스파", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "부산", + "region": "기장", + "regionKey": "kr.busan.gijang", + "regionType": "해변", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 역세권 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 시내 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 주차 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 도보 여행 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 벚꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "애월 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "애월 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "함덕해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "함덕해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "협재해수욕장 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "협재해수욕장 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "이호테우 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "이호테우 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "한림공원 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "한림공원 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "제주시 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "역세권", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "시내", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "제주시", + "regionKey": "kr.jeju.jejusi", + "regionType": "해변·도심", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.98 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 숙소", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.96 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 스파펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성펜션", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 글램핑", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채", + "kind": "keyword", + "intent": "local", + "category": "코어", + "tier": "주력", + "relevance": 0.9 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 순위", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 저렴한곳", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 가성비", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 실시간예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 당일예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 특가", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.88 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 독채펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 풀빌라 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 풀빌라 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 풀빌라 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 풀빌라 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "의도", + "tier": "보조", + "relevance": 0.8 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 가족 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 가족 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 친구 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 친구 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 단체 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 단체 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 아이동반 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 아이동반 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 부모님 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 부모님 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 4인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 4인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 6인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 6인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 2인 펜션", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 2인 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "동반자", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 산뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 숲속 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 불멍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 화로대 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 벽난로 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 단풍 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 바베큐 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 자쿠지 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 수영장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 프라이빗 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 복층 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 테라스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견운동장 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 넷플릭스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "보조", + "relevance": 0.83 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 오션뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 바다뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 노을뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 일출뷰 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 커플 바다 보이는 펜션", + "kind": "keyword", + "intent": "local", + "category": "시설", + "tier": "롱테일", + "relevance": 0.55 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 여름휴가 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 물놀이 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 해수욕 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 일출 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 낙조 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 겨울 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 눈꽃 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 연말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 크리스마스 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 주말 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 성수기 펜션", + "kind": "keyword", + "intent": "local", + "category": "시즌", + "tier": "보조", + "relevance": 0.76 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "중문색달해변 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "중문색달해변 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "성산일출봉 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "성산일출봉 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "쇠소깍 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "쇠소깍 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "천지연폭포 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "천지연폭포 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "우도 근처 펜션", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.84 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "우도 근처 숙소", + "kind": "keyword", + "intent": "local", + "category": "관광지", + "tier": "보조", + "relevance": 0.81 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 감성 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 조용한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 분위기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 사진찍기 좋은 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 인생샷 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 깔끔한 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 신축 숙소", + "kind": "keyword", + "intent": "local", + "category": "분위기", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 1박2일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 2박3일 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 주말여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 뚜벅이 여행 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 워케이션 숙소", + "kind": "keyword", + "intent": "local", + "category": "여행형태", + "tier": "롱테일", + "relevance": 0.66 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 어디가 좋아요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.72 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 1박 얼마인가요", + "kind": "keyword", + "intent": "transactional", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.7 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 애견동반 펜션 있나요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.68 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 바베큐 가능한가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.67 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 여행 몇박이 좋을까요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.6 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "서귀포 펜션 성수기 언제인가요", + "kind": "keyword", + "intent": "informational", + "category": "질문형", + "tier": "롱테일", + "relevance": 0.58 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "오션뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "바다뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "노을뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "일출뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "해변 근처", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "바다 보이는", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "산뷰", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "숲속", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "커플", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "가족", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "친구", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "애견동반", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "단체", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "감성", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "조용한", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "분위기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "제주", + "region": "서귀포", + "regionKey": "kr.jeju.seogwipo", + "regionType": "해변·산간", + "keyword": "사진찍기 좋은", + "kind": "tag", + "intent": "local", + "category": "태그", + "tier": "태그", + "relevance": 0.5 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경기", + "region": "경기", + "regionKey": "kr.gyeonggi", + "regionType": "", + "keyword": "경기 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "인천", + "region": "인천", + "regionKey": "kr.incheon", + "regionType": "", + "keyword": "인천 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "강원", + "region": "강원", + "regionKey": "kr.gangwon", + "regionType": "", + "keyword": "강원 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충북", + "region": "충북", + "regionKey": "kr.chungbuk", + "regionType": "", + "keyword": "충북 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "충남", + "region": "충남", + "regionKey": "kr.chungnam", + "regionType": "", + "keyword": "충남 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전북", + "region": "전북", + "regionKey": "kr.jeonbuk", + "regionType": "", + "keyword": "전북 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "전남", + "region": "전남", + "regionKey": "kr.jeonnam", + "regionType": "", + "keyword": "전남 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경북", + "region": "경북", + "regionKey": "kr.gyeongbuk", + "regionType": "", + "keyword": "경북 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "경남", + "region": "경남", + "regionKey": "kr.gyeongnam", + "regionType": "", + "keyword": "경남 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "부산", + "region": "부산", + "regionKey": "kr.busan", + "regionType": "", + "keyword": "부산 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 숙소", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 독채펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 풀빌라", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 애견펜션", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "주력", + "relevance": 0.92 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 펜션 추천", + "kind": "keyword", + "intent": "local", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 펜션 예약", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 펜션 가격", + "kind": "keyword", + "intent": "transactional", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + }, + { + "sido": "제주", + "region": "제주", + "regionKey": "kr.jeju", + "regionType": "", + "keyword": "제주 펜션 후기", + "kind": "keyword", + "intent": "informational", + "category": "광역", + "tier": "보조", + "relevance": 0.85 + } + ] +} diff --git a/ontology/data/regions.json b/ontology/data/regions.json new file mode 100644 index 0000000..606156a --- /dev/null +++ b/ontology/data/regions.json @@ -0,0 +1,803 @@ +{ + "note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령). ski=true 는 실제 스키장이 있는 지역 (산간이라고 다 스키장이 있는 건 아니다).", + "regions": [ + { + "sido": "경기", + "name": "가평", + "key": "kr.gyeonggi.gapyeong", + "type": [ + "호수", + "산간" + ], + "spots": [ + "남이섬", + "쁘띠프랑스", + "아침고요수목원", + "자라섬", + "청평호" + ] + }, + { + "sido": "경기", + "name": "양평", + "key": "kr.gyeonggi.yangpyeong", + "type": [ + "강변", + "산간" + ], + "spots": [ + "두물머리", + "세미원", + "용문사" + ] + }, + { + "sido": "경기", + "name": "포천", + "key": "kr.gyeonggi.pocheon", + "type": [ + "호수", + "산간" + ], + "spots": [ + "산정호수", + "포천아트밸리", + "허브아일랜드" + ] + }, + { + "sido": "경기", + "name": "파주", + "key": "kr.gyeonggi.paju", + "type": [ + "도심", + "강변" + ], + "spots": [ + "헤이리예술마을", + "임진각", + "프로방스마을" + ] + }, + { + "sido": "인천", + "name": "강화", + "key": "kr.incheon.ganghwa", + "type": [ + "해변", + "산간" + ], + "spots": [ + "마니산", + "동막해변", + "강화고인돌", + "전등사" + ] + }, + { + "sido": "인천", + "name": "을왕리", + "key": "kr.incheon.yeongjong", + "type": [ + "해변" + ], + "spots": [ + "을왕리해수욕장", + "무의도", + "하나개해수욕장" + ], + "aliases": [ + "영종도" + ] + }, + { + "sido": "강원", + "name": "춘천", + "key": "kr.gangwon.chuncheon", + "type": [ + "호수", + "도심" + ], + "spots": [ + "소양강스카이워크", + "김유정역", + "의암호", + "남이섬" + ] + }, + { + "sido": "강원", + "name": "홍천", + "key": "kr.gangwon.hongcheon", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "비발디파크", + "은행나무숲", + "홍천강" + ], + "ski": true + }, + { + "sido": "강원", + "name": "인제", + "key": "kr.gangwon.inje", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "자작나무숲", + "내린천", + "백담사" + ] + }, + { + "sido": "강원", + "name": "평창", + "key": "kr.gangwon.pyeongchang", + "type": [ + "산간" + ], + "spots": [ + "대관령", + "오대산", + "월정사", + "알펜시아", + "양떼목장" + ], + "ski": true + }, + { + "sido": "강원", + "name": "정선", + "key": "kr.gangwon.jeongseon", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "하이원리조트", + "레일바이크", + "병방치스카이워크" + ], + "ski": true + }, + { + "sido": "강원", + "name": "강릉", + "key": "kr.gangwon.gangneung", + "type": [ + "해변", + "도심" + ], + "spots": [ + "경포대", + "안목해변", + "정동진", + "주문진", + "오죽헌" + ] + }, + { + "sido": "강원", + "name": "속초", + "key": "kr.gangwon.sokcho", + "type": [ + "해변", + "산간" + ], + "spots": [ + "설악산", + "대포항", + "영금정", + "아바이마을", + "속초해수욕장" + ] + }, + { + "sido": "강원", + "name": "양양", + "key": "kr.gangwon.yangyang", + "type": [ + "해변" + ], + "spots": [ + "낙산사", + "죽도해변", + "하조대", + "서피비치", + "인구해변" + ] + }, + { + "sido": "강원", + "name": "고성", + "key": "kr.gangwon.goseong", + "type": [ + "해변" + ], + "spots": [ + "송지호", + "화진포", + "통일전망대", + "백섬해상전망대" + ] + }, + { + "sido": "강원", + "name": "동해", + "key": "kr.gangwon.donghae", + "type": [ + "해변" + ], + "spots": [ + "추암촛대바위", + "망상해수욕장", + "무릉계곡" + ] + }, + { + "sido": "강원", + "name": "삼척", + "key": "kr.gangwon.samcheok", + "type": [ + "해변", + "계곡" + ], + "spots": [ + "장호항", + "환선굴", + "맹방해변" + ] + }, + { + "sido": "강원", + "name": "태백", + "key": "kr.gangwon.taebaek", + "type": [ + "산간" + ], + "spots": [ + "태백산", + "검룡소", + "365세이프타운" + ], + "ski": true + }, + { + "sido": "충북", + "name": "단양", + "key": "kr.chungbuk.danyang", + "type": [ + "산간", + "강변" + ], + "spots": [ + "도담삼봉", + "만천하스카이워크", + "고수동굴", + "단양강잔도" + ] + }, + { + "sido": "충북", + "name": "제천", + "key": "kr.chungbuk.jecheon", + "type": [ + "호수", + "산간" + ], + "spots": [ + "청풍호", + "의림지", + "배론성지" + ] + }, + { + "sido": "충북", + "name": "충주", + "key": "kr.chungbuk.chungju", + "type": [ + "호수", + "산간" + ], + "spots": [ + "탄금대", + "수안보온천", + "충주호" + ] + }, + { + "sido": "충북", + "name": "괴산", + "key": "kr.chungbuk.goesan", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "산막이옛길", + "화양구곡" + ] + }, + { + "sido": "충남", + "name": "태안", + "key": "kr.chungnam.taean", + "type": [ + "해변" + ], + "spots": [ + "만리포해수욕장", + "꽃지해변", + "안면도", + "신두리해안사구", + "청포대" + ] + }, + { + "sido": "충남", + "name": "대천", + "key": "kr.chungnam.boryeong", + "type": [ + "해변" + ], + "spots": [ + "대천해수욕장", + "무창포", + "죽도상화원", + "성주산" + ], + "aliases": [ + "보령" + ] + }, + { + "sido": "충남", + "name": "서산", + "key": "kr.chungnam.seosan", + "type": [ + "해변", + "도심" + ], + "spots": [ + "해미읍성", + "간월암", + "개심사" + ] + }, + { + "sido": "충남", + "name": "공주", + "key": "kr.chungnam.gongju", + "type": [ + "도심", + "강변" + ], + "spots": [ + "공산성", + "무령왕릉", + "마곡사" + ] + }, + { + "sido": "충남", + "name": "부여", + "key": "kr.chungnam.buyeo", + "type": [ + "도심", + "강변" + ], + "spots": [ + "궁남지", + "부소산성", + "백제문화단지" + ] + }, + { + "sido": "전북", + "name": "군산", + "key": "kr.jeonbuk.gunsan", + "type": [ + "해변", + "도심" + ], + "spots": [ + "선유도", + "고군산군도", + "말랭이마을", + "경암동 철길마을", + "근대역사박물관", + "은파호수공원", + "새만금" + ] + }, + { + "sido": "전북", + "name": "변산", + "key": "kr.jeonbuk.buan", + "type": [ + "해변", + "산간" + ], + "spots": [ + "채석강", + "변산해수욕장", + "내소사", + "격포항" + ], + "aliases": [ + "부안" + ] + }, + { + "sido": "전북", + "name": "전주", + "key": "kr.jeonbuk.jeonju", + "type": [ + "도심" + ], + "spots": [ + "전주한옥마을", + "경기전", + "전동성당", + "남부시장" + ] + }, + { + "sido": "전북", + "name": "무주", + "key": "kr.jeonbuk.muju", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "덕유산", + "무주리조트", + "반디랜드", + "구천동계곡" + ], + "ski": true + }, + { + "sido": "전북", + "name": "남원", + "key": "kr.jeonbuk.namwon", + "type": [ + "도심", + "산간" + ], + "spots": [ + "광한루원", + "지리산", + "춘향테마파크" + ] + }, + { + "sido": "전남", + "name": "여수", + "key": "kr.jeonnam.yeosu", + "type": [ + "해변", + "도심" + ], + "spots": [ + "오동도", + "돌산대교", + "향일암", + "여수해상케이블카", + "낭만포차" + ] + }, + { + "sido": "전남", + "name": "순천", + "key": "kr.jeonnam.suncheon", + "type": [ + "도심", + "습지" + ], + "spots": [ + "순천만습지", + "낙안읍성", + "순천만국가정원" + ] + }, + { + "sido": "전남", + "name": "담양", + "key": "kr.jeonnam.damyang", + "type": [ + "도심", + "계곡" + ], + "spots": [ + "죽녹원", + "메타세쿼이아길", + "소쇄원" + ] + }, + { + "sido": "전남", + "name": "구례", + "key": "kr.jeonnam.gurye", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "지리산", + "화엄사", + "섬진강", + "사성암" + ] + }, + { + "sido": "전남", + "name": "해남", + "key": "kr.jeonnam.haenam", + "type": [ + "해변", + "산간" + ], + "spots": [ + "땅끝마을", + "두륜산", + "대흥사" + ] + }, + { + "sido": "전남", + "name": "완도", + "key": "kr.jeonnam.wando", + "type": [ + "해변", + "섬" + ], + "spots": [ + "청산도", + "명사십리해수욕장", + "완도타워" + ] + }, + { + "sido": "전남", + "name": "보성", + "key": "kr.jeonnam.boseong", + "type": [ + "도심", + "해변" + ], + "spots": [ + "보성녹차밭", + "율포해수욕장" + ] + }, + { + "sido": "경북", + "name": "경주", + "key": "kr.gyeongbuk.gyeongju", + "type": [ + "도심", + "호수" + ], + "spots": [ + "불국사", + "첨성대", + "황리단길", + "보문단지", + "동궁과월지" + ] + }, + { + "sido": "경북", + "name": "포항", + "key": "kr.gyeongbuk.pohang", + "type": [ + "해변", + "도심" + ], + "spots": [ + "호미곶", + "영일대해수욕장", + "죽도시장", + "스페이스워크" + ] + }, + { + "sido": "경북", + "name": "안동", + "key": "kr.gyeongbuk.andong", + "type": [ + "도심", + "강변" + ], + "spots": [ + "하회마을", + "월영교", + "도산서원" + ] + }, + { + "sido": "경북", + "name": "영덕", + "key": "kr.gyeongbuk.yeongdeok", + "type": [ + "해변" + ], + "spots": [ + "강구항", + "블루로드", + "고래불해수욕장" + ] + }, + { + "sido": "경북", + "name": "울진", + "key": "kr.gyeongbuk.uljin", + "type": [ + "해변", + "산간" + ], + "spots": [ + "죽변항", + "덕구온천", + "성류굴", + "후포항" + ] + }, + { + "sido": "경북", + "name": "문경", + "key": "kr.gyeongbuk.mungyeong", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "문경새재", + "단산모노레일", + "에코랄라" + ] + }, + { + "sido": "경남", + "name": "거제", + "key": "kr.gyeongnam.geoje", + "type": [ + "해변", + "섬" + ], + "spots": [ + "외도보타니아", + "바람의언덕", + "학동몽돌해변", + "매미성", + "windy hill" + ] + }, + { + "sido": "경남", + "name": "통영", + "key": "kr.gyeongnam.tongyeong", + "type": [ + "해변", + "섬" + ], + "spots": [ + "동피랑벽화마을", + "통영케이블카", + "미륵산", + "한산도", + "장사도" + ] + }, + { + "sido": "경남", + "name": "남해", + "key": "kr.gyeongnam.namhae", + "type": [ + "해변", + "섬" + ], + "spots": [ + "다랭이마을", + "독일마을", + "상주은모래비치", + "보리암" + ] + }, + { + "sido": "경남", + "name": "하동", + "key": "kr.gyeongnam.hadong", + "type": [ + "산간", + "강변" + ], + "spots": [ + "화개장터", + "쌍계사", + "섬진강", + "최참판댁" + ] + }, + { + "sido": "경남", + "name": "사천", + "key": "kr.gyeongnam.sacheon", + "type": [ + "해변" + ], + "spots": [ + "삼천포대교", + "사천케이블카", + "실안노을길" + ] + }, + { + "sido": "경남", + "name": "산청", + "key": "kr.gyeongnam.sancheong", + "type": [ + "산간", + "계곡" + ], + "spots": [ + "지리산", + "동의보감촌", + "대원사계곡" + ] + }, + { + "sido": "부산", + "name": "기장", + "key": "kr.busan.gijang", + "type": [ + "해변" + ], + "spots": [ + "해동용궁사", + "일광해수욕장", + "아난티코브", + "죽성성당" + ] + }, + { + "sido": "제주", + "name": "제주시", + "key": "kr.jeju.jejusi", + "type": [ + "해변", + "도심" + ], + "spots": [ + "애월", + "함덕해수욕장", + "협재해수욕장", + "이호테우", + "한림공원" + ] + }, + { + "sido": "제주", + "name": "서귀포", + "key": "kr.jeju.seogwipo", + "type": [ + "해변", + "산간" + ], + "spots": [ + "중문색달해변", + "성산일출봉", + "쇠소깍", + "천지연폭포", + "우도" + ] + } + ] +} diff --git a/ontology/data/related-keywords.sample.csv b/ontology/data/related-keywords.sample.csv new file mode 100644 index 0000000..dcf6a1c --- /dev/null +++ b/ontology/data/related-keywords.sample.csv @@ -0,0 +1,8 @@ +# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다. +# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것) +relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx +군산독채펜션,210,1830,중간 +군산감성숙소,90,760,낮음 +군산2인펜션,40,310,낮음 +군산뚜벅이여행,30,240,낮음 +말랭이마을숙소,10,90,낮음 diff --git a/ontology/data/배포용_키워드_DB덤프.xlsx b/ontology/data/배포용_키워드_DB덤프.xlsx new file mode 100644 index 0000000..b4375ce Binary files /dev/null and b/ontology/data/배포용_키워드_DB덤프.xlsx differ diff --git a/ontology/data/전국_펜션_SEO_AEO_키워드.xlsx b/ontology/data/전국_펜션_SEO_AEO_키워드.xlsx new file mode 100644 index 0000000..9d4b59e Binary files /dev/null and b/ontology/data/전국_펜션_SEO_AEO_키워드.xlsx differ diff --git a/ontology/docker-compose.yml b/ontology/docker-compose.yml new file mode 100644 index 0000000..4f85276 --- /dev/null +++ b/ontology/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: ontology-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ontology + POSTGRES_PASSWORD: ontology + POSTGRES_DB: ontology + ports: + - '55432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology'] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + container_name: ontology-redis + restart: unless-stopped + ports: + - '56379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 20 + +volumes: + pgdata: diff --git a/ontology/docs/architecture.html b/ontology/docs/architecture.html new file mode 100644 index 0000000..4b4392a --- /dev/null +++ b/ontology/docs/architecture.html @@ -0,0 +1,714 @@ +키워드 온톨로지 설계 + + + + + +
+ +
+

o2o-site-ontology

+

발행 사이트에 붙는
SEO/AEO 키워드 온톨로지

+

+ 업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다. + LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고, + 발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다. +

+
+ PostgreSQL 16 + pgvector + NestJS + BullMQ + OpenAI Structured Outputs +
+
+ + +
+
+

일반 DB 냐 벡터 DB 냐

+

둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
조회 유형실제 질의필요한 것
정확 조회업체 A 의 활성 키워드 20개B-tree / 관계형 조인
의미 조회이 후보가 기존 키워드와 의미상 겹치는가vector (HNSW)
관계 탐색업종 트리 상위에서 물려받을 공통 키워드ltree 계층 / recursive CTE
+
+ +
+

결론 — PostgreSQL 하나로 시작한다. + pgvector + ltree + pg_trgm + JSONB 로 세 가지가 모두 한 엔진 안에서 해결되고, + 무엇보다 키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.

+

전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. + 벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다. + Neo4j 도 같은 논리 — 고정 깊이 상속이면 ltree 로 충분하다.

+
+
+ + +
+
+

전체 흐름

+

생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + 트리거 + 사이트 발행 — 즉시 + 크론 03:00 — 30일 경과 + 성과 저조 — 재생성 + + BullMQ 큐 + 60초 dedupe 창 + 재시도 3회 · 지수 백오프 + 동시성 2 + + 생성 워커 + OpenAI · gpt-4.1-mini + Structured Outputs + 임베딩 배치 1회 + + 중복제거 4단계 + 해시 → trigram → 벡터 + 미일치만 신규 등록 + 나머지는 alias 흡수 + + PostgreSQL 16 + pgvector · ltree · pg_trgm + keyword (전역 사전) + merchant_keyword + qa_pair · generation_run + + Serving API + GET /v1/sites/:id/seo + GET /v1/sites/:id/aeo + 읽기 99% · 캐시 대상 + + 발행된 사이트 + o2o-site-AEO + 렌더링 시 호출 + + 성과 수집 + Search Console · 네이버 서치어드바이저 · 유입 로그 + + + + + + + + + + + + + + + + + + + + + + 적재 + job + 후보 + write + 읽기 + SEO / AEO payload + 노출 · 클릭 + CTR < 0.2% → 강등 + + 기존 키워드 주입 — 중복 후보 생성 자체를 억제 + +
+
+ 점선 화살표가 이 설계의 핵심이다. 프롬프트에 해당 업종의 기존 키워드를 넣어 중복 후보가 만들어지기 전에 줄이고, + 그래도 남는 것만 중복제거 단계가 처리한다. 생성 경로(위)와 서빙 경로(아래)는 Postgres 에서만 만나므로 + OpenAI 가 느리거나 죽어도 발행된 사이트의 응답에는 영향이 없다. +
+
+
+ + +
+
+

중복제거 4단계

+

+ 값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다. +

+
+ +
+
+ + + + + + + + + + + + + + + + + LLM 후보 키워드 + + + + + + + + + + + + + 0 · 금칙어 필터 + 최고 · 1위 · 100% · 완치 + + 1 · normalized 완전 일치 + NFKC · 소문자 · 구두점/공백 제거 + + 2 · pg_trgm 유사도 ≥ 0.6 + 표기 변형 · 오타 + + 3 · 코사인 유사도 ≥ 0.92 + 의미 중복 — 후보 20건 안에서만 + + 4 · 새 키워드로 INSERT + embedding 저장 · usage_count 1 + + + + + + + + + + + 미일치 + 미일치 + 미일치 + 미일치 + + + + + 비용 0 + B-tree 1회 + GIN trgm + HNSW top-20 + INSERT + + + + + + 차단 — 저장하지 않음 + rejected_banned + + + + + + + + + + + + + 기존 키워드에 alias 흡수 + 강남 뿌리 염색 → 강남 뿌리염색 + + 기존 키워드에 alias 흡수 + 강남 뿌리염색약 → 강남 뿌리염색 (0.67) + + 기존 키워드에 alias 흡수 + 강남 헤어샵 → 강남 미용실 (0.94) + + + + + + + 어느 경로든 업체에는 연결된다 + merchant_keyword · relevance · status + +
+
+ 1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다. + 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이 + 강남 뿌리염색약 으로 성과를 보고해도 같은 키워드에 매칭된다. +
+
+ +
+

실제 로컬 실행 결과

+

같은 지역·업종 업체를 순서대로 발행했을 때 npm run smoke 출력이다.

+
+
1. 레브살롱 (첫 업체)      후보 19 → 신규 19 / 중복 0
+2. 헤어랩 강남점            후보 19 → 신규 4  / 중복(정확 15, 표기 0, 의미 0)
+3. 강남 뷰티랩              후보 16 → 신규 3  / 중복(정확 12, 표기 1, 의미 0)
+
+   matched_exact    강남 뿌리 염색      (sim=1.000 → '강남 뿌리염색')
+   matched_trigram  강남 뿌리염색약     (sim=0.667 → '강남 뿌리염색')
+   matched_exact    강남미용실추천      (sim=1.000 → '강남 미용실 추천')
+
+

+ 참고 + 위 수치는 LLM_PROVIDER=mock 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. + 의미 중복(강남 미용실강남 헤어샵)은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다. +

+
+
+ + +
+
+

데이터 모델

+

+ 키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다. +

+
+ +
+
+ + + + + + + + + + + + + + + + + + industry + path ltree · beauty.hair + + region + path ltree · kr.seoul.gangnam + + merchant + external_id ← 사이트 ID + description · profile jsonb + last_generated_at + + merchant_keyword + relevance · status · source + impressions · clicks · ctr + PK (merchant_id, keyword_id) + + keyword — 전역 사전 + canonical · 표시용 + normalized UNIQUE · 판정용 + aliases text[] · 흡수된 표기 + embedding vector(1536) HNSW + intent · locale + usage_count + + qa_pair + question · answer + normalized_question UNIQUE + embedding vector(1536) + + + + + + + + + + + + 업종 분류 + 지역 분류 + 1 : N + N : 1 + 1 : N + + +
+
+ 강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. + 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있으므로 사전을 오염시키지 않고 + 업체마다 다른 순위를 낼 수 있다. +
+
+
+ + +
+
+

API

+

+ :id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다. + 연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다. +

+
+ +
+ + + + + + + + + + + + + +
메서드경로용도
GET/health헬스체크 · 현재 LLM provider 확인
POST/v1/merchants/publish사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재. sync:true 면 동기 실행
POST/v1/merchants/:id/generate수동 재생성. ?sync=true 로 결과를 즉시 확인
GET/v1/sites/:id/seo발행 사이트가 렌더링 시 호출. title · description · keywords · tags(alias 포함)
GET/v1/sites/:id/aeo답변엔진용 topics · FAQ · structuredDataHints
POST/v1/keywords/search어드민 — 자연어 질의로 키워드 사전 벡터 검색
POST/v1/sites/:id/performance노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등
+
+ +
+

SEO 응답

+
+
$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
+
+{
+  "title": "레브살롱 | 강남 미용실",
+  "description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
+  "keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
+  "tags": [
+    { "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
+      "aliases": ["강남미용실"] }
+  ]
+}
+ +
+

AEO 응답

+

+ SEO 가 키워드라면 AEO 는 질문-답변 쌍과 구조화 데이터다. AI 검색 크롤러가 인용하는 것은 이쪽이다. + structuredDataHints 는 후속 단계에서 LocalBusiness / FAQPage JSON-LD 로 그대로 매핑되도록 + 필드를 미리 맞춰 두었다. +

+
+
{
+  "topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
+  "faqs": [
+    { "question": "레브살롱은(는) 어디에 있나요?",
+      "answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
+  ],
+  "structuredDataHints": {
+    "type": "LocalBusiness", "name": "레브살롱",
+    "areaServed": "강남", "category": "미용실"
+  }
+}
+
+ + +
+
+

기술 선택

+
+
+ + + + + + + + + + +
레이어선택이유
런타임NestJS · TypeScripto2o-site-AEO 와 payload 타입을 공유할 수 있다
DBPostgreSQL 16 + pgvector
+ ltree + pg_trgm
정확 · 의미 · 계층 조회 3-in-1
DB 접근postgres.js (raw SQL)벡터 연산자 <=>ltree 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다
큐 · 스케줄BullMQ + Redis60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장
LLMOpenAI Structured Outputs
text-embedding-3-small
JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다
관측generation_run 테이블프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다
+
+ +
+

로컬 실행

+
+
npm install
+cp .env.example .env      # 기본 LLM_PROVIDER=mock — API 키 불필요
+npm run db:up             # postgres(pgvector) + redis
+npm run db:migrate && npm run db:seed
+npm start                 # http://localhost:3100
+npm run smoke             # 다른 터미널 — 엔드투엔드 점검
+
+ + +
+
+

남은 작업

+

연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.

+
    +
  • next JSON-LD 조립 — structuredDataHintsLocalBusiness / FAQPage / Service
  • +
  • next /llms.txt 서빙 — AI 검색 크롤러 진입점
  • +
  • later 업종 ltree 상위 노드 키워드 상속 (source: 'inherited')
  • +
  • later Redis 응답 캐시 — 서빙은 읽기 99%, TTL 1시간 + 발행 이벤트 무효화
  • +
  • later Search Console API 직접 연동 (지금은 /performance 수동 주입)
  • +
  • later 키워드 승인 · 차단 어드민 UI
  • +
+
+
+ +
+ o2o-site-ontology · 설계 문서 · 코드와 함께 docs/architecture.html 에 보관 +
+
diff --git a/ontology/docs/site-ontology 아키텍쳐.pptx b/ontology/docs/site-ontology 아키텍쳐.pptx new file mode 100644 index 0000000..9690470 Binary files /dev/null and b/ontology/docs/site-ontology 아키텍쳐.pptx differ diff --git a/ontology/drizzle/0000_init.sql b/ontology/drizzle/0000_init.sql new file mode 100644 index 0000000..3de6ede --- /dev/null +++ b/ontology/drizzle/0000_init.sql @@ -0,0 +1,124 @@ +-- o2o-site-ontology : initial schema +-- pgvector(유사도) + ltree(업종/지역 계층) + pg_trgm(표기 변형) 3-in-1 + +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS ltree; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- ---------------------------------------------------------------- 분류 계층 +CREATE TABLE IF NOT EXISTS industry ( + id text PRIMARY KEY, + path ltree NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS industry_path_gist ON industry USING gist (path); + +CREATE TABLE IF NOT EXISTS region ( + id text PRIMARY KEY, + path ltree NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS region_path_gist ON region USING gist (path); + +-- ---------------------------------------------------------------- 업체 +CREATE TABLE IF NOT EXISTS merchant ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + external_id text NOT NULL UNIQUE, -- o2o-site-AEO 의 사이트/업체 ID + name text NOT NULL, + industry_id text REFERENCES industry(id), + region_id text REFERENCES region(id), + description text NOT NULL DEFAULT '', + profile jsonb NOT NULL DEFAULT '{}'::jsonb, + site_url text, + last_generated_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS merchant_industry_idx ON merchant (industry_id); +CREATE INDEX IF NOT EXISTS merchant_stale_idx ON merchant (last_generated_at NULLS FIRST); + +-- ---------------------------------------------------------------- 전역 키워드 사전 +DO $$ BEGIN + CREATE TYPE keyword_intent AS ENUM + ('informational','navigational','transactional','local','brand'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS keyword ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + canonical text NOT NULL, -- 화면 노출용 대표 표기 + normalized text NOT NULL, -- 중복 판정용 정규화 표기 + locale text NOT NULL DEFAULT 'ko-KR', + aliases text[] NOT NULL DEFAULT '{}', -- 흡수된 표기 변형 (롱테일 확보) + intent keyword_intent NOT NULL DEFAULT 'informational', + industry_id text REFERENCES industry(id), + region_id text REFERENCES region(id), + embedding vector(1536), + usage_count integer NOT NULL DEFAULT 0, -- 몇 개 업체가 쓰는가 + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT keyword_normalized_locale_uq UNIQUE (normalized, locale) +); +CREATE INDEX IF NOT EXISTS keyword_embedding_hnsw + ON keyword USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS keyword_normalized_trgm + ON keyword USING gin (normalized gin_trgm_ops); +CREATE INDEX IF NOT EXISTS keyword_industry_idx ON keyword (industry_id); + +-- ---------------------------------------------------------------- 업체 <-> 키워드 +DO $$ BEGIN + CREATE TYPE merchant_keyword_status AS ENUM + ('candidate','active','demoted','blocked'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS merchant_keyword ( + merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE, + keyword_id uuid NOT NULL REFERENCES keyword(id) ON DELETE CASCADE, + relevance real NOT NULL DEFAULT 0, + source text NOT NULL DEFAULT 'llm', -- llm | manual | inherited + status merchant_keyword_status NOT NULL DEFAULT 'candidate', + rationale text, + impressions bigint NOT NULL DEFAULT 0, + clicks bigint NOT NULL DEFAULT 0, + ctr real NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (merchant_id, keyword_id) +); +CREATE INDEX IF NOT EXISTS merchant_keyword_serving_idx + ON merchant_keyword (merchant_id, status, relevance DESC); + +-- ---------------------------------------------------------------- AEO 질문-답변 +CREATE TABLE IF NOT EXISTS qa_pair ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE, + question text NOT NULL, + answer text NOT NULL, + normalized_question text NOT NULL, + embedding vector(1536), + status merchant_keyword_status NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT qa_pair_merchant_question_uq UNIQUE (merchant_id, normalized_question) +); +CREATE INDEX IF NOT EXISTS qa_pair_merchant_idx ON qa_pair (merchant_id, status); + +-- ---------------------------------------------------------------- 생성 감사 로그 +CREATE TABLE IF NOT EXISTS generation_run ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + merchant_id uuid REFERENCES merchant(id) ON DELETE CASCADE, + provider text NOT NULL, + model text NOT NULL, + prompt_version text NOT NULL, + trigger text NOT NULL, -- published | scheduled | manual + status text NOT NULL DEFAULT 'running', -- running | succeeded | failed + input jsonb NOT NULL DEFAULT '{}'::jsonb, + output jsonb NOT NULL DEFAULT '{}'::jsonb, + stats jsonb NOT NULL DEFAULT '{}'::jsonb, + error text, + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz +); +CREATE INDEX IF NOT EXISTS generation_run_merchant_idx + ON generation_run (merchant_id, started_at DESC); diff --git a/ontology/drizzle/0001_embedding_384.sql b/ontology/drizzle/0001_embedding_384.sql new file mode 100644 index 0000000..39b5dc9 --- /dev/null +++ b/ontology/drizzle/0001_embedding_384.sql @@ -0,0 +1,22 @@ +-- 임베딩 모델 전환: 로컬 multilingual-e5-small (384차원) +-- mock/openai 도 384 로 통일한다 (openai 는 dimensions 파라미터로 축소 요청). +DO $$ +BEGIN + IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute + WHERE attrelid = 'keyword'::regclass AND attname = 'embedding') <> 'vector(384)' THEN + DROP INDEX IF EXISTS keyword_embedding_hnsw; + ALTER TABLE keyword ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384); + CREATE INDEX keyword_embedding_hnsw ON keyword USING hnsw (embedding vector_cosine_ops); + END IF; + + IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute + WHERE attrelid = 'qa_pair'::regclass AND attname = 'embedding') <> 'vector(384)' THEN + ALTER TABLE qa_pair ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384); + END IF; +END $$; + +-- 데이터셋 출처 추적 (수작업 큐레이션 / LLM 생성 구분) +ALTER TABLE keyword ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'llm'; +ALTER TABLE keyword ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'keyword'; +ALTER TABLE keyword ADD COLUMN IF NOT EXISTS category text; +CREATE INDEX IF NOT EXISTS keyword_kind_idx ON keyword (kind, category); diff --git a/ontology/nest-cli.json b/ontology/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/ontology/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/ontology/package-lock.json b/ontology/package-lock.json new file mode 100644 index 0000000..cb46fba --- /dev/null +++ b/ontology/package-lock.json @@ -0,0 +1,6333 @@ +{ + "name": "o2o-site-ontology", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "o2o-site-ontology", + "version": "0.1.0", + "dependencies": { + "@huggingface/transformers": "^4.2.0", + "@nestjs/bullmq": "^11.0.2", + "@nestjs/common": "^11.0.12", + "@nestjs/core": "^11.0.12", + "@nestjs/platform-express": "^11.0.12", + "@nestjs/schedule": "^5.0.1", + "bullmq": "^5.44.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "openai": "^4.89.0", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.5", + "@nestjs/schematics": "^11.0.2", + "@types/express": "^5.0.1", + "@types/node": "^22.13.14", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", + "integrity": "sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.27.tgz", + "integrity": "sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.5.tgz", + "integrity": "sha512-QI7GHPk4oePBwszAZn/Kz5hgB79KijB1RDoo+E/BrSt33sHUrvRZXVyO0+knZleyloRYy0LT2VwsqSP/aQvg4Q==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.5.tgz", + "integrity": "sha512-u0VGVc5aER8A+4jEYXDin2rtqIHR8sFL68RUgNDgZslvjdeb1QL4CF+Sr0v4zMXJEfaLSVD606Oq0cryNNCxHg==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.5", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.24", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", + "integrity": "sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.2", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.3.tgz", + "integrity": "sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.3.tgz", + "integrity": "sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.3.tgz", + "integrity": "sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schedule": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.1.tgz", + "integrity": "sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==", + "license": "MIT", + "dependencies": { + "cron": "3.5.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.81.4", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.4.tgz", + "integrity": "sha512-n+WHSzz20KooBGoJyASre6oJNz/p5f1IJRRN2ibD+NWPQaNpNQmDrwYuPO+bsXrQeM1MQzUxXGbdjmyOFKP2xQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.5", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cron": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.5.0.tgz", + "integrity": "sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "deprecated": "v4 is no longer maintained, upgrade to v5", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.425", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz", + "integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.12", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz", + "integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/ontology/package.json b/ontology/package.json new file mode 100644 index 0000000..c73a55b --- /dev/null +++ b/ontology/package.json @@ -0,0 +1,51 @@ +{ + "name": "o2o-site-ontology", + "version": "0.1.0", + "description": "SEO/AEO keyword ontology service for o2o-site-AEO", + "private": true, + "scripts": { + "setup": "bash scripts/setup.sh", + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main.js", + "db:up": "docker compose up -d", + "db:down": "docker compose down", + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts", + "db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed", + "smoke": "tsx scripts/smoke.ts", + "dataset:build": "node scripts/build-dataset.mjs", + "dataset:ingest": "tsx scripts/ingest-dataset.ts", + "dataset:purge": "tsx scripts/purge-nondataset.ts", + "dataset:import-related": "tsx scripts/import-related.ts", + "dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py", + "dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts", + "db:dump": "bash scripts/db-dump.sh", + "db:export-xlsx": "python3 scripts/export-db-xlsx.py" + }, + "dependencies": { + "@huggingface/transformers": "^4.2.0", + "@nestjs/bullmq": "^11.0.2", + "@nestjs/common": "^11.0.12", + "@nestjs/core": "^11.0.12", + "@nestjs/platform-express": "^11.0.12", + "@nestjs/schedule": "^5.0.1", + "bullmq": "^5.44.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "openai": "^4.89.0", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.5", + "@nestjs/schematics": "^11.0.2", + "@types/express": "^5.0.1", + "@types/node": "^22.13.14", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/ontology/public/demo.html b/ontology/public/demo.html new file mode 100644 index 0000000..cff0648 --- /dev/null +++ b/ontology/public/demo.html @@ -0,0 +1,333 @@ + + + + + +키워드 매칭 콘솔 · o2o-site-ontology + + + +
+
+

O2O-SITE-ONTOLOGY

+

키워드 매칭 콘솔

+

업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.

+
연결 확인 중…
+
+ +
+ +
+ + +
+ + +
+
+ +
+ +
+ +
+
+
+
+
+
+ + +
#키워드유사도의도카테고리
매칭 버튼을 눌러 시작하세요
+
+
+
+
+ + + + diff --git a/ontology/scripts/build-dataset.mjs b/ontology/scripts/build-dataset.mjs new file mode 100644 index 0000000..9595273 --- /dev/null +++ b/ontology/scripts/build-dataset.mjs @@ -0,0 +1,204 @@ +/** + * "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. + * node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json + * + * 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고, + * 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다. + * 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다. + */ +import { writeFileSync, mkdirSync } from 'node:fs'; + +// ──────────────────────────────────────────────── 어휘 (실제 군산 기반) +const REGION = '군산'; + +// 고군산군도·해안 권역 +const ISLANDS = ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도']; +// 시내·주요 권역 +const AREAS = ['새만금', '비응항', '오식도', '은파호수공원', '월명동', '나운동', '수송동', '미룡동', '옥도면', + '군산 원도심', '신흥동', '영화동']; +// 관광지 (근처 숙소 검색의 앵커) +const SPOTS = [ + '선유도해수욕장', '새만금방조제', '경암동 철길마을', '근대역사박물관', '초원사진관', + '이성당', '동국사', '진포해양테마공원', '신흥동 일본식가옥', '은파호수공원', + '월명공원', '금강하구둑', '철새조망대', '채만식문학관', '째보선창', '시간여행마을', + '말랭이마을', '군산 근대문화역사거리', '해망굴', '군산항 뜬다리부두', +]; + +// 숙소 유형 +const STAY_CORE = ['펜션', '숙소', '풀빌라', '독채펜션', '스파펜션', '애견펜션', '감성펜션']; +const STAY_ALT = ['글램핑', '카라반', '캠핑장', '게스트하우스', '민박', '리조트', '한옥펜션', '촌집', '별장', + '독채스테이', '감성숙소', '스테이', '일본식가옥 숙소']; + +// 검색 의도어 +const INTENT_CORE = ['추천', '예약', '가격', '후기', '순위']; +const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약', '당일예약', '특가', '할인', + '위치', '주차', '전화번호', '체크인시간', '조식포함', '청소상태']; + +// 동반자 +const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체']; +const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵', + '회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인']; + +// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어 +const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집', + '깔끔한', '신축', '프라이빗한', '혼자 있기 좋은']; + +// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다 +const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행', + '워케이션', '무박', '한달살기']; + +// 시설·특징 +const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장']; +const FEAT_MORE = ['노을뷰', '일출뷰', '월풀', '온수풀', '야외수영장', '인피니티풀', '불멍', '화로대', + '넷플릭스', '빔프로젝터', '노래방', '파티룸', '복층', '테라스', '마당', '벽난로', + '애견운동장', '키즈룸', '트램폴린', '무료주차', '조식', '세미나실']; + +// 시즌·행사 +const SEASON = ['여름휴가', '물놀이', '해수욕', '겨울', '연말', '크리스마스', '신정', '설날', '추석', + '봄', '벚꽃', '가을', '단풍', '일출', '낙조', '불꽃놀이', '성수기', '비수기', '주말', '평일']; +const OCCASION = ['생일', '기념일', '결혼기념일', '프러포즈', '100일', '가족여행', '워크샵', '단합대회']; + +// ──────────────────────────────────────────────── 방출 +const rows = []; +const seen = new Set(); + +const INTENT_MAP = { + 예약: 'transactional', 실시간예약: 'transactional', 당일예약: 'transactional', + 가격: 'transactional', 최저가: 'transactional', 특가: 'transactional', 할인: 'transactional', + 후기: 'informational', 순위: 'informational', 청소상태: 'informational', 체크인시간: 'informational', +}; +const intentOf = (m) => INTENT_MAP[m] ?? 'local'; + +function add(keyword, { intent = 'local', kind = 'keyword', category, relevance }) { + const k = keyword.replace(/\s+/g, ' ').trim(); + if (!k || seen.has(k)) return false; + seen.add(k); + rows.push({ keyword: k, intent, kind, category, relevance: Math.round(relevance * 100) / 100 }); + return true; +} + +// T1 — 코어: 지역 × 숙소유형 × 의도 +for (const s of STAY_CORE) add(`${REGION} ${s}`, { category: '코어', relevance: 0.97 }); +for (const s of STAY_CORE) for (const m of INTENT_CORE) + add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 }); +for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 }); +for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 }); +// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다 +for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박']) + add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 }); + +// T2 — 섬·권역 × 숙소유형 +for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4)) + add(`${p} ${s}`, { category: '권역', relevance: g === ISLANDS ? 0.9 : 0.85 }); +for (const p of ISLANDS) for (const m of INTENT_CORE) + add(`${p} 펜션 ${m}`, { intent: intentOf(m), category: '권역', relevance: 0.82 }); + +// T3 — 동반자 +for (const w of WITH_CORE) { + add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.91 }); + for (const m of INTENT_CORE) add(`${REGION} ${w} 펜션 ${m}`, { intent: intentOf(m), category: '동반자', relevance: 0.8 }); + for (const s of STAY_CORE.slice(2, 6)) add(`${REGION} ${w} ${s}`, { category: '동반자', relevance: 0.78 }); +} +for (const w of WITH_MORE) { + add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.82 }); + add(`${REGION} ${w} 펜션 추천`, { category: '동반자', relevance: 0.75 }); + add(`${REGION} ${w} 숙소`, { category: '동반자', relevance: 0.73 }); +} + +// T4 — 시설·특징 +for (const f of FEAT_CORE) { + add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.89 }); + add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.76 }); + for (const m of INTENT_CORE.slice(0, 3)) add(`${REGION} ${f} 펜션 ${m}`, { intent: intentOf(m), category: '시설', relevance: 0.72 }); +} +for (const f of FEAT_MORE) { + add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.79 }); + add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.7 }); +} +for (const p of ISLANDS.slice(0, 4)) for (const f of FEAT_CORE) + add(`${p} ${f} 펜션`, { category: '시설', relevance: 0.74 }); + +// T5 — 시즌·행사 +for (const s of SEASON) { + add(`${REGION} ${s} 펜션`, { category: '시즌', relevance: 0.8 }); + add(`${REGION} ${s} 펜션 예약`, { intent: 'transactional', category: '시즌', relevance: 0.71 }); + add(`${s} ${REGION} 숙소`, { category: '시즌', relevance: 0.68 }); +} +for (const o of OCCASION) { + add(`${REGION} ${o} 펜션`, { category: '시즌', relevance: 0.75 }); + add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 }); +} + +// T5.5 — 분위기·여행형태 +for (const v of VIBE) { + add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 }); + add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 }); + add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 }); +} +for (const t of TRAVEL) { + add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 }); + add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 }); +} +for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3)) + add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 }); + +// T6 — 관광지 앵커 +for (const sp of SPOTS) { + add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 }); + add(`${sp} 근처 숙소`, { category: '관광지', relevance: 0.8 }); + add(`${sp} 펜션 추천`, { category: '관광지', relevance: 0.72 }); + add(`${sp} 가까운 숙소`, { category: '관광지', relevance: 0.66 }); +} + +// T7 — 태그 (칩 UI 용 짧은 패싯) +const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT, + '오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능', + '단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷', + ...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심']; +for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 }); + +// T8 — 질문형 (AEO) +const Q = []; +for (const w of [...WITH_CORE, '아이', '부모님']) Q.push([`${REGION} ${w} 펜션 어디가 좋아요`, 'informational', 0.7]); +for (const f of FEAT_CORE) Q.push([`${REGION}에 ${f} 펜션 있나요`, 'informational', 0.67]); +for (const p of ISLANDS.slice(0, 5)) { + Q.push([`${p} 펜션 어떻게 가나요`, 'informational', 0.64]); + Q.push([`${p} 숙소 예약 언제 해야 하나요`, 'informational', 0.6]); +} +Q.push([`${REGION} 펜션 1박 얼마인가요`, 'transactional', 0.72]); +Q.push([`${REGION} 펜션 바베큐 가능한가요`, 'informational', 0.7]); +Q.push([`${REGION} 펜션 체크인 몇시인가요`, 'informational', 0.66]); +Q.push([`${REGION} 펜션 주차 되나요`, 'informational', 0.66]); +Q.push([`${REGION} 애견동반 펜션 추가요금 있나요`, 'informational', 0.63]); +Q.push([`${REGION} 펜션 성수기 언제인가요`, 'informational', 0.61]); +Q.push([`선유도 들어가는 배 시간표`, 'informational', 0.55]); +Q.push([`${REGION} 여행 몇박이 좋을까요`, 'informational', 0.54]); +for (const [k, i, r] of Q) add(k, { intent: i, category: '질문형', relevance: r }); + +// T9 — 롱테일: 동반자 × 시설 / 권역 × 동반자 / 시즌 × 동반자 +const LONGTAIL = []; +for (const w of WITH_CORE) for (const f of FEAT_CORE) LONGTAIL.push([`${REGION} ${w} ${f} 펜션`, 0.5]); +for (const p of ISLANDS) for (const w of WITH_CORE) LONGTAIL.push([`${p} ${w} 펜션`, 0.48]); +for (const s of SEASON) for (const w of WITH_CORE) LONGTAIL.push([`${REGION} ${s} ${w} 펜션`, 0.44]); +for (const f of FEAT_CORE) for (const f2 of FEAT_MORE) LONGTAIL.push([`${REGION} ${f} ${f2} 펜션`, 0.4]); +for (const a of AREAS) for (const f of FEAT_CORE) LONGTAIL.push([`${a} ${f} 펜션`, 0.42]); +for (const [k, r] of LONGTAIL) { + if (rows.length >= 1000) break; + add(k, { category: '롱테일', relevance: r }); +} + +const dataset = rows.slice(0, 1000); +mkdirSync('data', { recursive: true }); +writeFileSync('data/gunsan-pension-keywords.json', JSON.stringify({ + topic: '군산 펜션', + locale: 'ko-KR', + generatedBy: 'hand-authored vocabulary × search-pattern expansion', + count: dataset.length, + items: dataset, +}, null, 2) + '\n'); + +const by = (f) => dataset.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {}); +console.log(`✅ data/gunsan-pension-keywords.json ${dataset.length}건`); +console.log(' 카테고리:', by('category')); +console.log(' 의도 :', by('intent')); +console.log(' 종류 :', by('kind')); diff --git a/ontology/scripts/build-deck.py b/ontology/scripts/build-deck.py new file mode 100644 index 0000000..35770fe --- /dev/null +++ b/ontology/scripts/build-deck.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- +"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다. + python3 scripts/build-deck.py +도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다.""" + +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR +from pptx.oxml.ns import qn + +# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일) +INK = RGBColor(0x10, 0x18, 0x19) +INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E) +MUTED = RGBColor(0x63, 0x75, 0x7A) +LINE = RGBColor(0xD5, 0xDC, 0xDB) +BG = RGBColor(0xF4, 0xF6, 0xF5) +SURFACE = RGBColor(0xFF, 0xFF, 0xFF) +SURF2 = RGBColor(0xEC, 0xEF, 0xF0) +ACCENT = RGBColor(0x0D, 0x6A, 0x60) +ACC_BG = RGBColor(0xDF, 0xF0, 0xEC) +WARN = RGBColor(0x8A, 0x5A, 0x06) +WARN_BG = RGBColor(0xF6, 0xEA, 0xD2) +STOP = RGBColor(0x9D, 0x3A, 0x30) +STOP_BG = RGBColor(0xF6, 0xE0, 0xDC) + +SANS = 'Apple SD Gothic Neo' # macOS 기본 한글 산세리프 +MONO = 'Menlo' + +W, H = 13.333, 7.5 +MX = 0.75 # 좌우 여백 + + +# ---------------------------------------------------------------- 저수준 헬퍼 +def _ea(run, name): + """한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정.""" + rPr = run._r.get_or_add_rPr() + for tag in ('a:ea', 'a:cs'): + el = rPr.find(qn(tag)) + if el is None: + el = rPr.makeelement(qn(tag), {}) + rPr.append(el) + el.set('typeface', name) + + +def write(tf, lines, space=2): + """lines: [(text, size, bold, color, font)] — 첫 줄은 기존 문단 재사용.""" + tf.word_wrap = True + for i, spec in enumerate(lines): + text, size, bold, color = spec[0], spec[1], spec[2], spec[3] + font = spec[4] if len(spec) > 4 else SANS + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.space_after = Pt(space) + p.line_spacing = 1.15 + r = p.add_run() + r.text = text + r.font.size = Pt(size) + r.font.bold = bold + r.font.color.rgb = color + r.font.name = font + _ea(r, font if font != MONO else SANS) + return tf + + +def textbox(sl, x, y, w, h, lines, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, space=2): + tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = tb.text_frame + tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = anchor + write(tf, lines, space) + for p in tf.paragraphs: + p.alignment = align + return tb + + +def box(sl, x, y, w, h, lines=None, fill=SURF2, line=LINE, lw=1.0, + pad=0.12, anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT, space=2, rounded=True): + shape = sl.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE, + Inches(x), Inches(y), Inches(w), Inches(h)) + if fill is None: + shape.fill.background() + else: + shape.fill.solid() + shape.fill.fore_color.rgb = fill + if line is None: + shape.line.fill.background() + else: + shape.line.color.rgb = line + shape.line.width = Pt(lw) + if rounded: + shape.adjustments[0] = 0.09 + shape.shadow.inherit = False + tf = shape.text_frame + tf.margin_left = tf.margin_right = Inches(pad) + tf.margin_top = tf.margin_bottom = Inches(pad * 0.7) + tf.vertical_anchor = anchor + if lines: + write(tf, lines, space) + for p in tf.paragraphs: + p.alignment = align + return shape + + +def arrow(sl, x1, y1, x2, y2, color=INK, width=1.25, dash=False): + c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, + Inches(x1), Inches(y1), Inches(x2), Inches(y2)) + c.line.color.rgb = color + c.line.width = Pt(width) + ln = c.line._get_or_add_ln() + if dash: + ln.append(ln.makeelement(qn('a:prstDash'), {'val': 'dash'})) + ln.append(ln.makeelement(qn('a:tailEnd'), {'type': 'triangle', 'w': 'med', 'len': 'med'})) + return c + + +def elbow(sl, pts, color=INK, width=1.25, dash=False): + """직교 경로: 마지막 구간에만 화살촉.""" + for i in range(len(pts) - 1): + (x1, y1), (x2, y2) = pts[i], pts[i + 1] + if i == len(pts) - 2: + arrow(sl, x1, y1, x2, y2, color, width, dash) + else: + c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, + Inches(x1), Inches(y1), Inches(x2), Inches(y2)) + c.line.color.rgb = color + c.line.width = Pt(width) + if dash: + c.line._get_or_add_ln().append( + c.line._get_or_add_ln().makeelement(qn('a:prstDash'), {'val': 'dash'})) + + +def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w=2.4): + return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align) + + +# ---------------------------------------------------------------- 슬라이드 골격 +prs = Presentation() +prs.slide_width = Inches(W) +prs.slide_height = Inches(H) +BLANK = prs.slide_layouts[6] + + +def slide(title=None, lede=None, eyebrow=None): + sl = prs.slides.add_slide(BLANK) + bg = sl.background.fill + bg.solid() + bg.fore_color.rgb = BG + y = 0.42 + if eyebrow: + textbox(sl, MX, y, 8, 0.22, [(eyebrow.upper(), 9, True, ACCENT, MONO)]) + y += 0.30 + if title: + textbox(sl, MX, y, W - 2 * MX, 0.5, [(title, 26, True, INK)]) + y += 0.62 + if lede: + textbox(sl, MX, y, W - 2 * MX - 1.2, 0.4, [(lede, 12.5, False, MUTED)]) + y += 0.46 + return sl, y + 0.22 + + +def table(sl, x, y, w, cols, rows, widths=None, fs=10.5, hfs=8.5, rowh=0.34): + shape = sl.shapes.add_table(len(rows) + 1, len(cols), Inches(x), Inches(y), + Inches(w), Inches(rowh * (len(rows) + 1))) + t = shape.table + t.first_row = False + if widths: + for i, ww in enumerate(widths): + t.columns[i].width = Inches(ww) + for i, c in enumerate(cols): + cell = t.cell(0, i) + cell.fill.solid(); cell.fill.fore_color.rgb = BG + cell.margin_left = cell.margin_right = Inches(0.09) + cell.vertical_anchor = MSO_ANCHOR.MIDDLE + write(cell.text_frame, [(c.upper(), hfs, True, MUTED, MONO)]) + for r, row in enumerate(rows, start=1): + t.rows[r].height = Inches(rowh) + for i, val in enumerate(row): + cell = t.cell(r, i) + cell.fill.solid(); cell.fill.fore_color.rgb = SURFACE + cell.margin_left = cell.margin_right = Inches(0.09) + cell.margin_top = cell.margin_bottom = Inches(0.04) + cell.vertical_anchor = MSO_ANCHOR.MIDDLE + mono = val.startswith('`') + write(cell.text_frame, + [(val.lstrip('`'), fs, False, INK_SOFT, MONO if mono else SANS)]) + return t + + +def footer(sl, n): + textbox(sl, MX, H - 0.52, 6, 0.24, + [('o2o-site-ontology', 8.5, False, MUTED, MONO)]) + textbox(sl, W - MX - 1.2, H - 0.52, 1.2, 0.24, + [(f'{n:02d}', 8.5, False, MUTED, MONO)], align=PP_ALIGN.RIGHT) + + +# ================================================================ 1 표지 +sl = prs.slides.add_slide(BLANK) +sl.background.fill.solid(); sl.background.fill.fore_color.rgb = BG +box(sl, 0, 0, 0.16, H, fill=ACCENT, line=None, rounded=False) +textbox(sl, 1.3, 1.95, 10, 0.3, [('O2O-SITE-ONTOLOGY', 10.5, True, ACCENT, MONO)]) +textbox(sl, 1.3, 2.35, 11, 1.5, + [('발행 사이트에 붙는', 40, True, INK), ('SEO/AEO 키워드 온톨로지', 40, True, INK)], space=4) +textbox(sl, 1.3, 4.15, 8.6, 1.0, + [('업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.', 13, False, INK_SOFT), + ('LLM 이 후보를 만들고, 4단계 중복제거가 전역 사전을 깨끗하게 유지하고,', 13, False, INK_SOFT), + ('발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.', 13, False, INK_SOFT)], space=3) +for i, chip in enumerate(['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs']): + wch = 0.16 + len(chip) * 0.082 + box(sl, 1.3 + sum(0.16 + len(c) * 0.082 + 0.14 for c in + ['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs'][:i]), + 5.5, wch, 0.32, [(chip, 9, False, MUTED, MONO)], + fill=SURFACE, line=LINE, pad=0.08, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER) +textbox(sl, 1.3, 6.5, 8, 0.24, [('설계 문서 · 로컬 구현 검증 완료', 9.5, False, MUTED, MONO)]) + +# ================================================================ 2 DB 선택 +sl, y = slide('일반 DB 냐 벡터 DB 냐', '둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.', '설계 판단 1') +table(sl, MX, y, W - 2 * MX, + ['조회 유형', '실제 질의', '필요한 것'], + [['정확 조회', '업체 A 의 활성 키워드 20개', '`B-tree / 관계형 조인'], + ['의미 조회', '이 후보가 기존 키워드와 의미상 겹치는가', '`vector (HNSW)'], + ['관계 탐색', '업종 트리 상위에서 물려받을 공통 키워드', '`ltree 계층 / recursive CTE']], + widths=[2.3, 5.9, 3.633], rowh=0.42) +yy = y + 2.0 +box(sl, MX, yy, 0.06, 1.55, fill=ACCENT, line=None, rounded=False) +box(sl, MX + 0.06, yy, W - 2 * MX - 0.06, 1.55, + [('결론 — PostgreSQL 하나로 시작한다.', 14, True, ACCENT), + ('pgvector + ltree + pg_trgm + JSONB 로 세 가지가 한 엔진 안에서 해결되고, 무엇보다', 11.5, False, INK_SOFT), + ('키워드 조회에는 항상 "어느 업체의" 라는 조인이 따라붙는다.', 11.5, True, INK_SOFT), + ('', 6, False, INK_SOFT), + ('전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. 벡터 행이 1천만 건을', 11.5, False, INK_SOFT), + ('넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.', 11.5, False, INK_SOFT)], + fill=ACC_BG, line=None, pad=0.24, space=3, rounded=False) +footer(sl, 2) + +# ================================================================ 3 전체 흐름 +sl, y = slide('전체 흐름', '생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.', '아키텍처') +BW, BH = 2.15, 1.05 +xs = [MX, MX + 2.5, MX + 5.0, MX + 7.5, MX + 10.0] +r1 = y + 0.42 +box(sl, xs[0], r1, BW, BH, + [('트리거', 11.5, True, INK), ('사이트 발행 — 즉시', 9, False, MUTED), + ('크론 03:00 — 30일 경과', 9, False, MUTED), ('성과 저조 — 재생성', 9, False, MUTED)], space=1) +box(sl, xs[1], r1, BW, BH, + [('BullMQ 큐', 11.5, True, INK), ('60초 dedupe 창', 9, False, MUTED), + ('재시도 3회 · 백오프', 9, False, MUTED), ('동시성 2', 9, False, MUTED)], space=1) +box(sl, xs[2], r1, BW, BH, + [('생성 워커', 11.5, True, INK), ('OpenAI · gpt-4.1-mini', 9, False, MUTED), + ('Structured Outputs', 9, False, MUTED), ('임베딩 배치 1회', 9, False, MUTED)], space=1) +box(sl, xs[3], r1, BW, BH, + [('중복제거 4단계', 11.5, True, WARN), ('해시 → trigram → 벡터', 9, False, WARN), + ('미일치만 신규 등록', 9, False, WARN), ('나머지는 alias 흡수', 9, False, WARN)], + fill=WARN_BG, line=WARN, lw=1.4, space=1) +box(sl, xs[4], r1 - 0.14, 2.58, BH + 0.28, + [('PostgreSQL 16', 11.5, True, ACCENT), ('pgvector · ltree · pg_trgm', 9, False, ACCENT), + ('keyword (전역 사전)', 9, False, ACCENT), ('merchant_keyword', 9, False, ACCENT), + ('qa_pair · generation_run', 9, False, ACCENT)], + fill=ACC_BG, line=ACCENT, lw=1.4, space=1) +mid = r1 + BH / 2 +for i in range(4): + a, b = xs[i] + BW, xs[i + 1] + arrow(sl, a + 0.04, mid, b - 0.04, mid) +for i, t in enumerate(['적재', 'job', '후보', 'write']): + label(sl, xs[i] + BW + 0.02, mid - 0.28, t, 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=0.42) + +r2 = r1 + 2.05 +box(sl, xs[3], r2, BW, BH, + [('Serving API', 11.5, True, INK), ('GET /v1/sites/:id/seo', 9, False, MUTED), + ('GET /v1/sites/:id/aeo', 9, False, MUTED), ('읽기 99% · 캐시 대상', 9, False, MUTED)], space=1) +box(sl, xs[1], r2, BW, BH, + [('발행된 사이트', 11.5, True, INK), ('o2o-site-AEO', 9, False, MUTED), + ('렌더링 시 호출', 9, False, MUTED)], space=1) +box(sl, xs[1], r2 + 1.5, 4.65, 0.6, + [('성과 수집 · Search Console · 서치어드바이저 · 유입 로그', 9.5, False, INK)], + anchor=MSO_ANCHOR.MIDDLE, space=1) + +elbow(sl, [(xs[4] + 1.29, r1 - 0.14 + BH + 0.28), (xs[4] + 1.29, r2 + BH / 2), (xs[3] + BW + 0.04, r2 + BH / 2)]) +label(sl, xs[4] + 1.35, r2 - 0.05, '읽기', 8.5, w=0.6) +arrow(sl, xs[3] - 0.04, r2 + BH / 2, xs[1] + BW + 0.04, r2 + BH / 2) +label(sl, xs[1] + BW + 0.5, r2 + BH / 2 - 0.28, 'SEO / AEO payload', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.6) +arrow(sl, xs[1] + BW / 2, r2 + BH, xs[1] + BW / 2, r2 + 1.46) +label(sl, xs[1] + BW / 2 + 0.08, r2 + BH + 0.06, '노출 · 클릭', 8.5) +elbow(sl, [(xs[1], r2 + 1.8), (MX + 0.5, r2 + 1.8), (MX + 0.5, r1 + BH + 0.06)]) +label(sl, MX + 0.58, r2 + 0.9, 'CTR < 0.2% → 강등', 8.5) +elbow(sl, [(xs[4] + 1.29, r1 - 0.18), (xs[4] + 1.29, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.04)], + color=ACCENT, dash=True) +label(sl, xs[2] + BW / 2, r1 - 0.78, '기존 키워드 주입 — 중복 후보 생성 자체를 억제', 9, ACCENT, MONO, PP_ALIGN.CENTER, w=4.6) +footer(sl, 3) + +# ================================================================ 4 중복제거 +sl, y = slide('중복제거 4단계', '값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어난다.', '핵심 메커니즘') +SX, SW, SH, GAP = 3.55, 3.5, 0.66, 0.19 +stages = [ + ('0 · 금칙어 필터', '최고 · 1위 · 100% · 완치', '비용 0', STOP, STOP_BG, '차단 — 저장하지 않음', 'rejected_banned'), + ('1 · normalized 완전 일치', 'NFKC · 소문자 · 공백/구두점 제거', 'B-tree 1회', WARN, WARN_BG, 'alias 흡수', '강남 뿌리 염색 → 강남 뿌리염색'), + ('2 · pg_trgm 유사도 ≥ 0.6', '표기 변형 · 오타', 'GIN trgm', WARN, WARN_BG, 'alias 흡수', '강남 뿌리염색약 → 강남 뿌리염색 (0.67)'), + ('3 · 코사인 유사도 ≥ 0.92', '의미 중복 — 후보 20건 안에서만', 'HNSW top-20', WARN, WARN_BG, 'alias 흡수', '강남 헤어샵 → 강남 미용실 (0.94)'), +] +top = y + 0.28 +label(sl, SX, top - 0.30, 'LLM 후보 키워드', 11, INK, SANS, PP_ALIGN.CENTER, w=SW) +for i, (t, sub, cost, col, colbg, exit_t, exit_s) in enumerate(stages): + yy = top + i * (SH + GAP) + box(sl, SX, yy, SW, SH, [(t, 11, True, INK), (sub, 8.5, False, MUTED)], space=1) + label(sl, SX - 1.55, yy + 0.20, cost, 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45) + arrow(sl, SX + SW + 0.04, yy + SH / 2, SX + SW + 0.7, yy + SH / 2, color=col) + box(sl, SX + SW + 0.74, yy, 4.3, SH, + [(exit_t, 10, True, col), (exit_s, 8.5, False, col, MONO)], + fill=colbg, line=col, lw=1.1, space=1) + if i < len(stages) - 1: + arrow(sl, SX + SW / 2, yy + SH, SX + SW / 2, yy + SH + GAP - 0.02) +last = top + len(stages) * (SH + GAP) +arrow(sl, SX + SW / 2, last - GAP, SX + SW / 2, last - 0.02) +box(sl, SX, last, SW, SH, + [('4 · 새 키워드로 INSERT', 11, True, ACCENT), ('embedding 저장 · usage_count 1', 8.5, False, ACCENT)], + fill=ACC_BG, line=ACCENT, lw=1.4, space=1) +label(sl, SX - 1.55, last + 0.20, 'INSERT', 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45) +box(sl, SX + SW + 0.74, last, 4.3, SH, + [('어느 경로든 업체에는 연결된다', 10, True, INK), ('merchant_keyword · relevance · status', 8.5, False, MUTED, MONO)], + fill=None, line=INK, lw=1.0, space=1) +textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4, + [('1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다 — 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지된다.', + 10, False, MUTED)]) +footer(sl, 4) + +# ================================================================ 5 데이터 모델 +sl, y = slide('데이터 모델', '키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거가 성립하지 않는다.', '스키마') +c1, c2, c3 = MX, MX + 4.7, MX + 8.9 +box(sl, c1, y + 0.15, 2.6, 0.62, [('industry', 10.5, True, INK, MONO), ('path ltree · beauty.hair', 8.5, False, MUTED, MONO)], space=1) +box(sl, c1, y + 0.97, 2.6, 0.62, [('region', 10.5, True, INK, MONO), ('path ltree · kr.jeonbuk.gunsan', 8.5, False, MUTED, MONO)], space=1) +box(sl, c1, y + 2.35, 2.6, 1.15, + [('merchant', 10.5, True, INK, MONO), ('external_id ← 사이트 ID', 8.5, False, MUTED, MONO), + ('description · profile jsonb', 8.5, False, MUTED, MONO), ('last_generated_at', 8.5, False, MUTED, MONO)], space=1) +box(sl, c2, y + 2.35, 3.1, 1.15, + [('merchant_keyword', 10.5, True, INK, MONO), ('relevance · status · source', 8.5, False, MUTED, MONO), + ('impressions · clicks · ctr', 8.5, False, MUTED, MONO), ('PK (merchant_id, keyword_id)', 8.5, False, MUTED, MONO)], space=1) +box(sl, c3, y + 0.15, 3.6, 1.95, + [('keyword — 전역 사전', 10.5, True, ACCENT, MONO), ('canonical · 표시용', 8.5, False, ACCENT, MONO), + ('normalized UNIQUE · 판정용', 8.5, False, ACCENT, MONO), ('aliases text[] · 흡수된 표기', 8.5, False, ACCENT, MONO), + ('embedding vector(1536) HNSW', 8.5, False, ACCENT, MONO), ('intent · locale · usage_count', 8.5, False, ACCENT, MONO)], + fill=ACC_BG, line=ACCENT, lw=1.4, space=1) +box(sl, c3, y + 2.55, 3.6, 0.95, + [('qa_pair', 10.5, True, INK, MONO), ('question · answer', 8.5, False, MUTED, MONO), + ('normalized_question UNIQUE', 8.5, False, MUTED, MONO), ('embedding vector(1536)', 8.5, False, MUTED, MONO)], space=1) +arrow(sl, c1 + 2.64, y + 0.46, c3 - 0.04, y + 0.55) +label(sl, c1 + 3.0, y + 0.18, '업종 분류', 8.5) +arrow(sl, c1 + 2.64, y + 1.28, c3 - 0.04, y + 1.20) +label(sl, c1 + 3.0, y + 1.32, '지역 분류', 8.5) +arrow(sl, c1 + 2.64, y + 2.92, c2 - 0.04, y + 2.92) +label(sl, c1 + 2.75, y + 2.62, '1 : N', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.9) +elbow(sl, [(c2 + 3.14, y + 2.75), (c3 - 0.35, y + 2.75), (c3 - 0.35, y + 1.1), (c3 - 0.04, y + 1.1)]) +label(sl, c3 - 0.95, y + 1.85, 'N : 1', 8.5) +elbow(sl, [(c1 + 1.3, y + 3.54), (c1 + 1.3, y + 3.95), (c3 + 1.8, y + 3.95), (c3 + 1.8, y + 3.54)]) +label(sl, c2 + 1.3, y + 3.62, '1 : N', 8.5) +textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4, + [('강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있다.', + 10, False, MUTED)]) +footer(sl, 5) + +# ================================================================ 6 API +sl, y = slide('API', ':id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다 — 연동 쪽에 ID 매핑 테이블이 필요 없다.', '연동 표면') +table(sl, MX, y, W - 2 * MX, + ['메서드', '경로', '용도'], + [['`GET', '`/health', '헬스체크 · 현재 LLM provider 확인'], + ['`POST', '`/v1/merchants/publish', '사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재 (sync:true 면 동기)'], + ['`POST', '`/v1/merchants/:id/generate', '수동 재생성. ?sync=true&count=N'], + ['`GET', '`/v1/sites/:id/seo', '발행 사이트가 렌더링 시 호출. title · description · keywords · tags'], + ['`GET', '`/v1/sites/:id/aeo', '답변엔진용 topics · FAQ · structuredDataHints'], + ['`POST', '`/v1/keywords/search', '어드민 — 자연어 질의로 키워드 사전 벡터 검색'], + ['`POST', '`/v1/sites/:id/performance', '노출·클릭 주입 → CTR 갱신 → 저성과 강등']], + widths=[1.0, 3.5, 7.333], rowh=0.4) +box(sl, MX, y + 3.35, 5.75, 1.85, + [('SEO 응답', 10, True, ACCENT, MONO), + ('{', 9.5, False, INK_SOFT, MONO), + (' "title": "스테이머뭄 | 군산 펜션",', 9.5, False, INK_SOFT, MONO), + (' "keywords": ["스테이머뭄", "군산 펜션", …],', 9.5, False, INK_SOFT, MONO), + (' "tags": [{ "keyword": "군산 애견동반 펜션",', 9.5, False, INK_SOFT, MONO), + (' "relevance": 0.88, "aliases": [ … ] }]', 9.5, False, INK_SOFT, MONO), + ('}', 9.5, False, INK_SOFT, MONO)], + fill=SURFACE, space=1) +box(sl, MX + 6.05, y + 3.35, 5.78, 1.85, + [('AEO 응답 — 답변엔진이 인용하는 쪽', 10, True, ACCENT, MONO), + ('{', 9.5, False, INK_SOFT, MONO), + (' "topics": ["군산 펜션", "군산 커플 펜션"],', 9.5, False, INK_SOFT, MONO), + (' "faqs": [{ "question": "…근처에 가볼 만한 곳은?",', 9.5, False, INK_SOFT, MONO), + (' "answer": "선유도, 은파호수공원 …" }],', 9.5, False, INK_SOFT, MONO), + (' "structuredDataHints": { "type": "LocalBusiness" }', 9.5, False, INK_SOFT, MONO), + ('}', 9.5, False, INK_SOFT, MONO)], + fill=SURFACE, space=1) +footer(sl, 6) + +# ================================================================ 7 기술 선택 +sl, y = slide('기술 선택', None, '스택') +table(sl, MX, y, W - 2 * MX, + ['레이어', '선택', '이유'], + [['런타임', '`NestJS · TypeScript', 'o2o-site-AEO 와 payload 타입을 공유할 수 있다'], + ['DB', '`PostgreSQL 16 + pgvector + ltree + pg_trgm', '정확 · 의미 · 계층 조회 3-in-1'], + ['DB 접근', '`postgres.js (raw SQL)', '벡터 연산자와 ltree 는 어차피 raw SQL — ORM 을 얹으면 우회 코드가 더 는다'], + ['큐 · 스케줄', '`BullMQ + Redis', '60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장'], + ['LLM', '`OpenAI Structured Outputs / text-embedding-3-small', 'JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다'], + ['관측', '`generation_run 테이블', '프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다']], + widths=[1.5, 4.6, 5.733], rowh=0.44) +box(sl, MX, y + 3.5, W - 2 * MX, 1.5, + [('로컬 실행', 10, True, ACCENT, MONO), + ('npm install && cp .env.example .env # 기본 LLM_PROVIDER=mock — API 키 불필요', 10, False, INK_SOFT, MONO), + ('npm run db:up && npm run db:migrate && npm run db:seed', 10, False, INK_SOFT, MONO), + ('npm start # http://localhost:3100', 10, False, INK_SOFT, MONO), + ('npm run smoke # 다른 터미널 — 엔드투엔드 점검', 10, False, INK_SOFT, MONO)], + fill=SURFACE, space=2) +footer(sl, 7) + +# ================================================================ 8 검증 1 +sl, y = slide('로컬 검증 — 같은 지역·업종 3곳', '강남/미용실 업체를 순서대로 발행했을 때 중복제거가 실제로 어떻게 걸리는지.', '검증 1') +table(sl, MX, y, 7.4, + ['순서', '업체', '후보', '신규', '중복 (정확/표기/의미)'], + [['1', '레브살롱', '19', '19', '0 / 0 / 0'], + ['2', '헤어랩 강남점', '19', '4', '15 / 0 / 0'], + ['3', '강남 뷰티랩', '16', '3', '12 / 1 / 0']], + widths=[0.7, 2.4, 1.0, 1.0, 2.3], rowh=0.42) +box(sl, MX, y + 2.1, 7.4, 1.5, + [('matched_exact 강남 뿌리 염색 (sim=1.000 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO), + ('matched_trigram 강남 뿌리염색약 (sim=0.667 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO), + ('matched_exact 강남미용실추천 (sim=1.000 → \'강남 미용실 추천\')', 10, False, INK_SOFT, MONO)], + fill=SURFACE, space=3) +box(sl, MX + 7.8, y, 4.03, 3.6, + [('세 번째 업체에서는', 11, False, MUTED), + ('16개 중 3개만', 22, True, ACCENT), + ('새 키워드였다', 11, False, MUTED), + ('', 8, False, MUTED), + ('나머지 13개는 이미 사전에 있던', 10.5, False, INK_SOFT), + ('키워드에 흡수됐다. 업체가 늘어도', 10.5, False, INK_SOFT), + ('사전은 선형으로 늘지 않는다.', 10.5, False, INK_SOFT)], + fill=SURFACE, pad=0.24, space=4) +textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4, + [('참고 — LLM_PROVIDER=mock 기준. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. 의미 중복은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.', + 9.5, False, MUTED)]) +footer(sl, 8) + +# ================================================================ 9 검증 2 +sl, y = slide('로컬 검증 — 군산 스테이머뭄 200개', '펜션 한 곳으로 키워드 200개를 뽑아 pgvector 에 적재했을 때 실제로 무엇이 쌓이는가.', '검증 2') +box(sl, MX, y, 5.6, 0.95, + [('후보 200 → 신규 174 / 중복(표기 26) / 연결 174 / QA 5 799ms', 10.5, False, INK_SOFT, MONO), + ('keyword 174행 · 임베딩 174건 · 흡수된 표기 26개', 10.5, False, ACCENT, MONO)], + fill=SURFACE, space=3) +textbox(sl, MX, y + 1.25, 5.6, 0.3, [('relevance 분포', 11.5, True, INK)]) +dist = [('0.93~0.99', 4, '브랜드 · 핵심', ACCENT), + ('0.80~0.88', 32, '지역 × 업종 × 동반자', ACCENT), + ('0.72~0.76', 11, '시즌', INK_SOFT), + ('0.60~0.70', 51, '시설 · 서비스', INK_SOFT), + ('0.50', 6, '질문형', MUTED), + ('0.42', 40, '동반자 × 시설', STOP), + ('0.38', 30, '동반자 × 서비스', STOP)] +by = y + 1.62 +for i, (rng, n, note, col) in enumerate(dist): + yy = by + i * 0.36 + label(sl, MX, yy + 0.03, rng, 9, MUTED, MONO, PP_ALIGN.RIGHT, w=0.95) + box(sl, MX + 1.05, yy, max(0.06, n * 0.048), 0.24, fill=col, line=None, rounded=False) + label(sl, MX + 1.05 + max(0.06, n * 0.048) + 0.1, yy + 0.03, f'{n} {note}', 9, col, SANS, w=3.2) +box(sl, MX + 7.4, y, 4.43, 2.05, + [('하위 70개는 이런 것들', 11, True, STOP), + ('애견동반 바베큐장 0.38', 10, False, INK_SOFT, MONO), + ('태교여행 프라이빗 스파 0.38', 10, False, INK_SOFT, MONO), + ('커플 바베큐장 0.38', 10, False, INK_SOFT, MONO), + ('', 6, False, MUTED), + ('문법은 맞지만 아무도 이렇게 검색하지 않는다.', 10, False, MUTED)], + fill=STOP_BG, line=STOP, lw=1.1, space=2) +box(sl, MX + 7.4, y + 2.35, 4.43, 1.9, + [('잘 작동한 부분 — 벡터 검색', 11, True, ACCENT), + ('"선유도 근처 바베큐 되는 펜션"', 10, False, INK_SOFT, MONO), + (' 0.686 선유도 근처 펜션', 10, False, ACCENT, MONO), + (' 0.439 고군산군도 근처 펜션', 10, False, ACCENT, MONO), + (' 0.392 선유도 펜션 추천', 10, False, ACCENT, MONO)], + fill=SURFACE, space=2) +footer(sl, 9) + +# ================================================================ 10 발견 +sl, y = slide('발견 — 저장한 것의 89%는 쓰이지 않는다', None, '문제 정의') +box(sl, MX, y, 5.3, 2.5, + [('적재된 키워드 174개 중', 12, False, MUTED), + ('89%', 62, True, STOP), + ('가 한 번도 서빙되지 않는다 (서빙 20개 / 사장 154개)', 11.5, False, INK_SOFT)], + fill=STOP_BG, line=None, pad=0.3, space=6, anchor=MSO_ANCHOR.MIDDLE) +box(sl, MX + 5.7, y, 6.13, 2.5, + [('그런데 이 154개는', 12, True, INK), + ('· 매번 dedup 후보 검색 대상이고', 11.5, False, INK_SOFT), + ('· HNSW 인덱스에 들어가 있고', 11.5, False, INK_SOFT), + ('· 다음 생성 때 프롬프트에도 실린다', 11.5, False, INK_SOFT), + ('', 6, False, MUTED), + ('순수한 부채다. 주기 생성을 30일마다 돌리면 매달 반복된다.', 11.5, True, STOP)], + fill=SURFACE, pad=0.3, space=5) +textbox(sl, MX, y + 2.85, W - 2 * MX, 0.3, [('시간이 지나면 실제로 바뀌는 건 3가지뿐', 14, True, INK)]) +table(sl, MX, y + 3.3, W - 2 * MX, + ['무엇이 바뀌나', '올바른 대응', '현행 설계'], + [['업체 정보 (메뉴 추가, 이전, 서비스 변경)', '이벤트 기반 재생성', '30일 크론이 대신 처리'], + ['성과 데이터 누적', '재순위 — 생성이 아님', '재생성으로 오해'], + ['계절 · 트렌드 (연말 파티헤어, 여름 네일)', '업종 단위 생성 — 업체 수와 무관', '업체마다 중복 생성']], + widths=[4.6, 4.0, 3.233], rowh=0.4) +footer(sl, 10) + +# ================================================================ 11 개선 +sl, y = slide('개선 방향', '키워드는 업체 수 × 시간이 아니라 업체 수에만 비례해야 한다.', '다음 단계') +items = [ + ('relevance 컷', '0.6 미만 후보는 저장하지 않는다', '200개 → 110개. 저장조차 하지 말아야 할 것들.'), + ('업체당 정원제', 'active 슬롯 30개 고정', '새 후보는 최약체와 경쟁해서 이겨야 들어온다.\n시스템이 스스로 상한을 갖는다.'), + ('profile_hash', '업체 정보가 바뀔 때만 재생성', '정보가 그대로면 재생성해서 얻을 게 없다. 주기 크론이 사실상 무력화된다.'), + ('크론 성격 전환', '생성 → 정리', '고아 키워드 삭제, 저성과 강등. 늘리는 일이 아니라 줄이는 일.'), +] +for i, (t, s, d) in enumerate(items): + yy = y + i * 0.85 + box(sl, MX, yy, 0.42, 0.68, [(str(i + 1), 12, True, ACCENT, MONO)], + fill=SURFACE, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER, pad=0.02) + textbox(sl, MX + 0.62, yy + 0.02, 2.5, 0.3, [(t, 13, True, INK)]) + textbox(sl, MX + 3.2, yy + 0.04, 3.0, 0.3, [(s, 11, False, ACCENT, MONO)]) + textbox(sl, MX + 6.4, yy + 0.02, 5.4, 0.6, + [(ln, 10.5, False, MUTED) for ln in d.split('\n')], space=1) +box(sl, MX, y + 3.7, W - 2 * MX, 1.5, + [('그 뒤에 남은 작업', 11, True, ACCENT), + ('JSON-LD 조립 (structuredDataHints → LocalBusiness / FAQPage / Service) · /llms.txt 서빙', 11, False, INK_SOFT), + ('업종 ltree 상위 노드 키워드 상속 · Redis 응답 캐시 · Search Console API 직접 연동 · 키워드 승인/차단 어드민', 11, False, INK_SOFT)], + fill=ACC_BG, line=None, pad=0.26, space=4) +footer(sl, 11) + +prs.save('docs/architecture.pptx') +print('✅ docs/architecture.pptx') diff --git a/ontology/scripts/build-nationwide-dataset.mjs b/ontology/scripts/build-nationwide-dataset.mjs new file mode 100644 index 0000000..80b1f25 --- /dev/null +++ b/ontology/scripts/build-nationwide-dataset.mjs @@ -0,0 +1,160 @@ +/** + * 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. + * node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json + * + * 설계 원칙 + * · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고 + * 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용). + * · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다. + * 산간 지역에 '오션뷰 펜션'을 만들지 않는다. + * · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다. + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8')); + +// ── 공통 어휘 +const STAY = ['펜션', '숙소', '독채펜션', '풀빌라', '스파펜션', '애견펜션', '감성펜션', '글램핑', '독채']; +const INTENT = { 추천:'local', 예약:'transactional', 가격:'transactional', 후기:'informational', + 순위:'informational', 저렴한곳:'local', 가성비:'local', 실시간예약:'transactional', + 당일예약:'transactional', 특가:'transactional' }; +const WITH = ['커플', '가족', '친구', '애견동반', '단체', '아이동반', '부모님', '4인', '6인', '2인']; +const VIBE = ['감성', '조용한', '분위기 좋은', '사진찍기 좋은', '인생샷', '깔끔한', '신축']; +const TRAVEL = ['1박2일', '2박3일', '주말여행', '뚜벅이 여행', '워케이션']; + +// 지역 성격별 유효 시설 — 여기가 조합 폭발을 막는 장치다 +const FEATURES_BY_TYPE = { + 해변: ['오션뷰', '바다뷰', '노을뷰', '일출뷰', '해변 근처', '바다 보이는'], + 섬: ['오션뷰', '바다뷰', '배타고 가는', '섬'], + 산간: ['산뷰', '숲속', '불멍', '화로대', '벽난로', '단풍'], + 계곡: ['계곡', '물놀이', '계곡뷰', '불멍'], + 호수: ['호수뷰', '레이크뷰', '물놀이', '노을뷰'], + 강변: ['강뷰', '리버뷰', '노을뷰'], + 도심: ['역세권', '시내', '주차', '도보 여행'], + 습지: ['자연', '산책'], +}; +const FEATURES_COMMON = ['바베큐', '스파', '자쿠지', '수영장', '독채', '프라이빗', '복층', '테라스', '애견운동장', '넷플릭스']; + +const SEASON_BY_TYPE = { + 해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'], + 섬: ['여름휴가', '일출'], + 산간: ['겨울', '단풍', '눈꽃'], + 계곡: ['여름휴가', '물놀이', '단풍'], + 호수: ['여름휴가', '단풍', '벚꽃'], + 강변: ['벚꽃', '단풍'], + 도심: ['벚꽃', '연말'], + 습지: ['가을', '갈대'], +}; +const SEASON_COMMON = ['겨울', '연말', '크리스마스', '주말', '성수기']; + +const rows = []; +const seen = new Set(); +const norm = (s) => s.normalize('NFKC').toLowerCase().replace(/\s+/g, ''); + +function add(region, keyword, { intent = 'local', kind = 'keyword', category, tier, relevance }) { + const k = keyword.replace(/\s+/g, ' ').trim(); + const id = `${region.key}|${norm(k)}`; + if (!k || seen.has(id)) return; + seen.add(id); + rows.push({ + sido: region.sido, region: region.name, regionKey: region.key, + regionType: region.type.join('·'), + keyword: k, kind, intent, category, tier, + relevance: Math.round(relevance * 100) / 100, + }); +} + +const uniq = (a) => [...new Set(a)]; + +for (const r of regions) { + const R = r.name; + const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]); + // '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다. + const seasons = uniq([ + ...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []), + ...(r.ski ? ['스키', '스키장 근처', '보드'] : []), + ...SEASON_COMMON, + ]); + + // T1 코어 — 주력 후보. + // 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다. + // 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다. + const names = [R, ...(r.aliases ?? [])]; + for (const N of names) { + add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 }); + add(r, `${N} 숙소`, { category: '코어', tier: '주력', relevance: N === R ? 0.96 : 0.92 }); + for (const s of STAY.slice(2)) add(r, `${N} ${s}`, { category: '코어', tier: '주력', relevance: 0.9 }); + } + + // T2 의도 — 보조 + for (const N of names) + for (const [m, it] of Object.entries(INTENT)) + add(r, `${N} 펜션 ${m}`, { intent: it, category: '의도', tier: '보조', relevance: N === R ? 0.88 : 0.84 }); + for (const s of ['독채펜션', '풀빌라', '애견펜션', '감성펜션']) + for (const m of ['추천', '예약', '가격', '후기']) + add(r, `${R} ${s} ${m}`, { intent: INTENT[m], category: '의도', tier: '보조', relevance: 0.8 }); + + // T3 동반자 + for (const w of WITH) { + add(r, `${R} ${w} 펜션`, { category: '동반자', tier: '보조', relevance: 0.85 }); + add(r, `${R} ${w} 펜션 추천`, { category: '동반자', tier: '롱테일', relevance: 0.7 }); + } + + // T4 시설 — 지역 성격에 맞는 것만 + for (const f of feats) { + add(r, `${R} ${f} 펜션`, { category: '시설', tier: '보조', relevance: 0.83 }); + } + for (const f of feats.slice(0, 6)) + add(r, `${R} 커플 ${f} 펜션`, { category: '시설', tier: '롱테일', relevance: 0.55 }); + + // T5 시즌 + for (const s of seasons) add(r, `${R} ${s} 펜션`, { category: '시즌', tier: '보조', relevance: 0.76 }); + + // T6 관광지 앵커 — 지역 고유 + for (const sp of r.spots) { + add(r, `${sp} 근처 펜션`, { category: '관광지', tier: '보조', relevance: 0.84 }); + add(r, `${sp} 근처 숙소`, { category: '관광지', tier: '보조', relevance: 0.81 }); + } + + // T7 분위기·여행형태 + for (const v of VIBE) add(r, `${R} ${v} 숙소`, { category: '분위기', tier: '롱테일', relevance: 0.68 }); + for (const t of TRAVEL) add(r, `${R} ${t} 숙소`, { category: '여행형태', tier: '롱테일', relevance: 0.66 }); + + // T8 질문형 (AEO) + const qs = [ + [`${R} 펜션 어디가 좋아요`, 'informational', 0.72], + [`${R} 펜션 1박 얼마인가요`, 'transactional', 0.7], + [`${R} 애견동반 펜션 있나요`, 'informational', 0.68], + [`${R} 펜션 바베큐 가능한가요`, 'informational', 0.67], + [`${R} 여행 몇박이 좋을까요`, 'informational', 0.6], + [`${R} 펜션 성수기 언제인가요`, 'informational', 0.58], + ]; + for (const [q, it, rel] of qs) add(r, q, { intent: it, category: '질문형', tier: '롱테일', relevance: rel }); + + // T9 태그 (칩 UI) + for (const t of uniq([...feats.slice(0, 8), ...WITH.slice(0, 5), ...VIBE.slice(0, 4)])) + add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 }); +} + +// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다. +const sidoKey = {}; +for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.'); +const sidoList = uniq(regions.map((x) => x.sido)); +for (const sido of sidoList) { + const pseudo = { sido, name: sido, key: sidoKey[sido], type: [] }; + for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션']) + add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 }); + for (const m of ['추천', '예약', '가격', '후기']) + add(pseudo, `${sido} 펜션 ${m}`, { intent: INTENT[m], category: '광역', tier: '보조', relevance: 0.85 }); +} + +writeFileSync('data/nationwide-pension-keywords.json', + JSON.stringify({ topic: '전국 지역별 펜션', locale: 'ko-KR', + generatedBy: 'region master × search-pattern expansion (region-type aware)', + regionCount: regions.length, count: rows.length, items: rows }, null, 2) + '\n'); + +const by = (f) => rows.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {}); +console.log(`✅ data/nationwide-pension-keywords.json ${rows.length}건 / ${regions.length}개 지역`); +console.log(` 지역당 평균 ${Math.round(rows.length / (regions.length + sidoList.length))}건`); +console.log(' 티어:', by('tier')); +console.log(' 카테고리:', by('category')); diff --git a/ontology/scripts/db-dump.sh b/ontology/scripts/db-dump.sh new file mode 100755 index 0000000..dc28f74 --- /dev/null +++ b/ontology/scripts/db-dump.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 임베딩 포함 전체 덤프 — 배포 대상에서 재임베딩 없이 그대로 복원된다. +set -euo pipefail +OUT="${1:-data/ontology-dump.sql.gz}" +mkdir -p "$(dirname "$OUT")" +docker exec -i ontology-postgres pg_dump -U ontology -d ontology \ + --no-owner --no-privileges --clean --if-exists | gzip -9 > "$OUT" +echo "✅ $OUT ($(du -h "$OUT" | cut -f1))" +echo " 복원: gunzip -c $OUT | psql \"\$TARGET_DATABASE_URL\"" +echo " (대상 DB 에 vector · ltree · pg_trgm 확장이 설치돼 있어야 한다)" diff --git a/ontology/scripts/export-db-xlsx.py b/ontology/scripts/export-db-xlsx.py new file mode 100644 index 0000000..589c197 --- /dev/null +++ b/ontology/scripts/export-db-xlsx.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용). + python3 scripts/export-db-xlsx.py +데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 — +384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다.""" +import csv, io, subprocess, collections +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +OUT = 'data/배포용_키워드_DB덤프.xlsx' +CONT = 'ontology-postgres' +DB = ['psql', '-U', 'ontology', '-d', 'ontology', '-t', '-A', '--csv', '-c'] + +INK = '1F2A2B' +HEAD = PatternFill('solid', fgColor='0D6A60') +THIN = Side(style='thin', color='D5DCDB') +BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) +SRC_FILL = {'dataset': 'DFF0EC', 'nationwide': 'FFFFFF', 'manual': 'F6EAD2'} + + +def query(sql: str): + out = subprocess.run(['docker', 'exec', '-i', CONT, *DB, sql], + capture_output=True, text=True, check=True).stdout + return list(csv.reader(io.StringIO(out))) + + +def sheet(wb, title, header, rows, widths_, fill_col=None, first=False): + ws = wb.active if first else wb.create_sheet(title) + if first: ws.title = title + ws.append(header) + for r in rows: ws.append(r) + for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)): + fill = None + if fill_col is not None: + fill = PatternFill('solid', fgColor=SRC_FILL.get(row[fill_col].value, 'FFFFFF')) + for c in row: + c.font = Font(size=10, color=INK); c.border = BOX + if fill: c.fill = fill + for c in range(1, len(header) + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEAD; cell.font = Font(bold=True, color='FFFFFF', size=10) + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = BOX + ws.row_dimensions[1].height = 22 + ws.freeze_panes = 'A2' + ws.auto_filter.ref = f'A1:{get_column_letter(len(header))}{ws.max_row}' + for i, w in enumerate(widths_, start=1): + ws.column_dimensions[get_column_letter(i)].width = w + return ws + + +wb = Workbook() + +# 1) 키워드 — DB 전체 +kw = query(""" + SELECT k.id, k.canonical, k.normalized, k.locale, + array_to_string(k.aliases, ' | ') AS aliases, + k.intent, k.kind, COALESCE(k.category,'') AS category, k.source, + COALESCE(k.industry_id,'') , COALESCE(k.region_id,''), + COALESCE(r.name,''), COALESCE(sido.name,''), + k.usage_count, + (k.embedding IS NOT NULL) AS has_embedding, + to_char(k.updated_at,'YYYY-MM-DD HH24:MI') + FROM keyword k + LEFT JOIN region r ON r.id = k.region_id + LEFT JOIN region sido ON sido.id = regexp_replace(k.region_id, '\\.[^.]+$', '') + ORDER BY k.source, sido.name NULLS FIRST, r.name NULLS FIRST, k.canonical +""") +sheet(wb, '키워드', + ['id', '키워드', '정규화', 'locale', '흡수된 표기(alias)', '의도', '종류', '카테고리', + '출처', '업종ID', '지역ID', '지역', '시도', '사용업체수', '임베딩', '갱신일시'], + kw, [38, 30, 26, 8, 30, 14, 8, 10, 12, 14, 24, 12, 8, 10, 9, 17], + fill_col=8, first=True) + +# 2~5) 마스터 +sheet(wb, '지역', ['지역ID', '경로(ltree)', '지역명'], + query("SELECT id, path::text, name FROM region ORDER BY path"), [26, 26, 16]) +sheet(wb, '업종', ['업종ID', '경로(ltree)', '업종명'], + query("SELECT id, path::text, name FROM industry ORDER BY path"), [22, 22, 16]) +sheet(wb, '업체', + ['외부ID', '상호', '업종', '지역', '소개', '사이트', '프로필(JSON)'], + query("""SELECT m.external_id, m.name, COALESCE(i.name,''), COALESCE(r.name,''), + m.description, COALESCE(m.site_url,''), m.profile::text + FROM merchant m + LEFT JOIN industry i ON i.id=m.industry_id + LEFT JOIN region r ON r.id=m.region_id + ORDER BY m.external_id"""), + [14, 18, 12, 10, 50, 34, 70]) +sheet(wb, 'QA(AEO)', ['업체', '질문', '답변', '상태'], + query("""SELECT m.name, q.question, q.answer, q.status::text + FROM qa_pair q JOIN merchant m ON m.id=q.merchant_id + ORDER BY m.name, q.created_at"""), [16, 44, 70, 10]) + +# 5-b) 업체↔키워드 연결 +sheet(wb, '업체키워드', + ['업체', '키워드', '관련도', '상태', '출처', '노출수', '클릭수', 'CTR', '근거'], + query("""SELECT m.name, k.canonical, round(mk.relevance::numeric,2), mk.status::text, + mk.source, mk.impressions, mk.clicks, round(mk.ctr::numeric,4), + COALESCE(mk.rationale,'') + FROM merchant_keyword mk + JOIN merchant m ON m.id=mk.merchant_id + JOIN keyword k ON k.id=mk.keyword_id + ORDER BY m.name, mk.relevance DESC"""), + [16, 30, 9, 10, 10, 10, 9, 9, 28]) + +# 5-c) 생성 이력 (감사 로그) +sheet(wb, '생성이력', + ['업체', 'provider', 'model', '프롬프트버전', '트리거', '상태', '통계', '시작', '종료'], + query("""SELECT COALESCE(m.name,''), g.provider, g.model, g.prompt_version, + g.trigger, g.status, g.stats::text, + to_char(g.started_at,'YYYY-MM-DD HH24:MI'), + COALESCE(to_char(g.finished_at,'YYYY-MM-DD HH24:MI'),'') + FROM generation_run g + LEFT JOIN merchant m ON m.id=g.merchant_id + ORDER BY g.started_at DESC"""), + [16, 10, 20, 14, 12, 10, 60, 17, 17]) + +# 6) 배포 가이드 +counts = collections.Counter(r[8] for r in kw) +guide = [ + ('무엇이 들어있나', ''), + ('', f"벡터 DB(keyword 테이블)에 실제 적재된 {len(kw):,}건 전부. 데이터셋 JSON 이 아니라 DB 가 기준이다."), + ('', '출처별: ' + ' · '.join(f'{k} {v:,}' for k, v in counts.most_common())), + ('', 'dataset = 군산 상세(매칭 엔진 개발용) / nationwide = 전국 54개 지역'), + ('', 'DB 의 7개 테이블을 모두 담았다: keyword / region / industry / merchant /'), + ('', 'merchant_keyword / qa_pair / generation_run.'), + ('', ''), + ('임베딩은 왜 없나', ''), + ('', '384개 float × 7천 행이라 엑셀에 담을 수 없고 담아도 못 읽는다.'), + ('', '[임베딩] 열은 DB 에 벡터가 있는지만 표시한다.'), + ('', '같은 모델(Xenova/multilingual-e5-small)로 다시 만들면 동일한 값이 나오므로'), + ('', '텍스트만 있으면 복원된다.'), + ('', ''), + ('배포 방법 2가지', ''), + ('A. pg_dump (권장)', '임베딩 포함 그대로 복원. 재임베딩 불필요.'), + ('', ' npm run db:dump → data/ontology-dump.sql.gz'), + ('', ' gunzip -c data/ontology-dump.sql.gz | psql $TARGET_URL'), + ('B. 재적재', '텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.'), + ('', ' npm run db:migrate && npm run db:seed'), + ('', ' npm run dataset:ingest && npm run dataset:ingest-nationwide'), + ('', ''), + ('⚠ 검색량은 아직 비어있다', ''), + ('', '이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.'), + ('', '네이버 검색광고 키워드도구로 월간검색수를 채우고 월 10 미만을 걷어내야'), + ('', '실서비스에 쓸 수 있다. (npm run dataset:import-related 로 CSV 병합)'), +] +ws = sheet(wb, '배포가이드', ['항목', '내용'], guide, [22, 100]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2): + if row[0].value and not row[1].value: + row[0].font = Font(size=10, bold=True, color='0D6A60') + row[1].alignment = Alignment(wrap_text=True, vertical='center') + +wb.save(OUT) +print(f'✅ {OUT}') +print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets)) +print(f' 키워드 {len(kw):,}행 (' + ', '.join(f'{k} {v:,}' for k, v in counts.most_common()) + ')') diff --git a/ontology/scripts/export-xlsx.py b/ontology/scripts/export-xlsx.py new file mode 100644 index 0000000..62550ca --- /dev/null +++ b/ontology/scripts/export-xlsx.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +"""전국 펜션 키워드 데이터셋 → 엑셀. + python3 scripts/export-xlsx.py +검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리.""" +import json, collections +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +SRC = 'data/nationwide-pension-keywords.json' +OUT = 'data/전국_펜션_SEO_AEO_키워드.xlsx' + +INK = '1F2A2B' +ACC = '0D6A60' +HEAD = PatternFill('solid', fgColor='0D6A60') +BAND = PatternFill('solid', fgColor='F1F5F4') +TIER = {'주력': 'DFF0EC', '보조': 'FFFFFF', '롱테일': 'F7F7F5', '태그': 'F6EAD2'} +THIN = Side(style='thin', color='D5DCDB') +BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + +SLOT = { + '코어': '메인 페이지 (주력)', '광역': '광역 랜딩', + '의도': '메인 / 예약 페이지', '동반자': '객실 페이지', + '시설': '시설 페이지', '관광지': '주변 여행 페이지', + '시즌': '블로그 · 프로모션', '분위기': '블로그 · 소개', + '여행형태': '블로그 · 코스', '질문형': 'FAQ (AEO · FAQPage)', + '태그': '필터 UI (SEO 아님)', +} + +data = json.load(open(SRC, encoding='utf-8')) +items = data['items'] +regions = json.load(open('data/regions.json', encoding='utf-8'))['regions'] + +wb = Workbook() + +def style_header(ws, ncols, height=22): + for c in range(1, ncols + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEAD + cell.font = Font(bold=True, color='FFFFFF', size=10) + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = BOX + ws.row_dimensions[1].height = height + ws.freeze_panes = 'A2' + ws.auto_filter.ref = f'A1:{get_column_letter(ncols)}{ws.max_row}' + +def widths(ws, ws_widths): + for i, w in enumerate(ws_widths, start=1): + ws.column_dimensions[get_column_letter(i)].width = w + +# ────────────────────────────────── 1. 키워드 +ws = wb.active +ws.title = '키워드' +cols = ['시도', '지역', '지역키', '지역성격', '키워드', '종류', '의도', '카테고리', + '티어', '관련도', '월간검색수', '경쟁도', '추천 배치', '비고'] +ws.append(cols) +for it in items: + ws.append([ + it['sido'], it['region'], it['regionKey'], it['regionType'], + it['keyword'], it['kind'], it['intent'], it['category'], + it['tier'], it['relevance'], None, None, + SLOT.get(it['category'], ''), None, + ]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(cols)): + fill = PatternFill('solid', fgColor=TIER.get(row[8].value, 'FFFFFF')) + for c in row: + c.font = Font(size=10, color=INK) + c.border = BOX + c.fill = fill + row[9].number_format = '0.00' + row[10].number_format = '#,##0' + row[4].font = Font(size=10, bold=True, color=INK) +style_header(ws, len(cols)) +widths(ws, [8, 12, 24, 14, 30, 8, 14, 10, 9, 9, 12, 10, 22, 16]) + +# ────────────────────────────────── 2. 지역 마스터 +ws = wb.create_sheet('지역마스터') +ws.append(['시도', '지역', '지역키', '지역성격', '대표 관광지 (앵커)', '키워드 수']) +cnt = collections.Counter(i['regionKey'] for i in items) +for r in regions: + ws.append([r['sido'], r['name'], r['key'], '·'.join(r['type']), + ', '.join(r['spots']), cnt.get(r['key'], 0)]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=6): + for c in row: + c.font = Font(size=10, color=INK); c.border = BOX + c.alignment = Alignment(vertical='center', wrap_text=(c.column == 5)) +style_header(ws, 6) +widths(ws, [8, 14, 26, 14, 70, 10]) + +# ────────────────────────────────── 3. 지역별 요약 +ws = wb.create_sheet('지역별요약') +tiers = ['주력', '보조', '롱테일', '태그'] +ws.append(['시도', '지역'] + tiers + ['합계']) +per = collections.defaultdict(collections.Counter) +meta = {} +for i in items: + per[i['regionKey']][i['tier']] += 1 + meta[i['regionKey']] = (i['sido'], i['region']) +for key, c in sorted(per.items(), key=lambda kv: (-sum(kv[1].values()))): + sido, name = meta[key] + ws.append([sido, name] + [c[t] for t in tiers] + [sum(c.values())]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=7): + for c in row: + c.font = Font(size=10, color=INK); c.border = BOX +style_header(ws, 7) +widths(ws, [8, 14, 9, 9, 10, 9, 9]) + +# ────────────────────────────────── 4. 사용 가이드 +ws = wb.create_sheet('사용가이드') +guide = [ + ('이 파일은 무엇인가', ''), + ('', f"전국 {data['regionCount']}개 펜션 수요 지역 × 검색 패턴으로 만든 SEO/AEO 키워드 후보 {len(items):,}건."), + ('', '지역 성격(해변·산간·호수·도심·섬)에 맞는 시설 키워드만 전개했다. 산간 지역에 오션뷰 키워드는 없다.'), + ('', ''), + ('⚠ 반드시 먼저 읽을 것', ''), + ('', '이 키워드는 검색 패턴으로 생성한 것이지 실제 검색 데이터가 아니다.'), + ('', '네이버 검색광고 > 도구 > 키워드도구 에서 월간검색수를 받아 [월간검색수] 열을 채운 뒤'), + ('', '월 10 미만은 걷어내야 한다. 앞선 단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다.'), + ('', ''), + ('티어의 뜻', ''), + ('주력', '페이지의 대표 키워드 후보. 한 페이지에 1개만 쓴다.'), + ('보조', 'h2/h3 와 본문에 배치. 페이지당 3~5개.'), + ('롱테일', '블로그·상세 페이지용. 검색량 확인 후 취사선택.'), + ('태그', '사이트 필터 UI 용. SEO 키워드가 아니다.'), + ('', ''), + ('한 페이지에 몇 개를 넣나', ''), + ('', 'title 1개 · h1 1개 · meta description 2~3개 · h2/h3 3~5개 · 본문 5~10개'), + ('', 'meta keywords 태그는 쓰지 않는다 (구글은 2009년부터 랭킹에 반영하지 않는다).'), + ('', '한 페이지에 주력을 여러 개 넣으면 주제가 희석돼 어느 것으로도 안 잡힌다.'), + ('', ''), + ('AEO (답변엔진)', ''), + ('', '[카테고리=질문형] 행이 AEO 용이다. FAQPage 구조화 데이터로 8~15쌍 넣는다.'), + ('', '답변은 2~3문장, 업체 정보에 근거한 사실만 쓴다.'), + ('', ''), + ('다음 단계', ''), + ('', '1. 키워드도구로 [월간검색수]·[경쟁도] 채우기'), + ('', '2. 월 10 미만 행 제거'), + ('', '3. 관련도 높음 + 볼륨 중간 + 경쟁 낮음 조합을 우선 채택'), + ('', '4. [추천 배치] 열대로 페이지에 배분'), +] +ws.append(['항목', '내용']) +for a, b in guide: + ws.append([a, b]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2): + bold = bool(row[0].value) and not row[1].value + row[0].font = Font(size=10, bold=True, color=ACC if bold else INK) + row[1].font = Font(size=10, color=INK) + row[1].alignment = Alignment(wrap_text=True, vertical='center') +style_header(ws, 2) +widths(ws, [22, 100]) + +wb.save(OUT) +print(f'✅ {OUT}') +print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets)) +print(f' 키워드 {len(items):,}행 / 지역 {len(regions)}개') diff --git a/ontology/scripts/import-related.ts b/ontology/scripts/import-related.ts new file mode 100644 index 0000000..fe03af5 --- /dev/null +++ b/ontology/scripts/import-related.ts @@ -0,0 +1,90 @@ +/** + * 외부 연관키워드·검색량을 데이터셋에 병합한다. + * npx tsx scripts/import-related.ts data/related-keywords.csv [--apply] + * + * 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON. + * relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx + * + * API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이 + * 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고, + * 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; + +const DATASET = 'data/gunsan-pension-keywords.json'; + +interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null } + +function parse(path: string): Related[] { + const raw = readFileSync(path, 'utf8'); + if (path.endsWith('.json')) { + return (JSON.parse(raw) as any[]).map(toRelated); + } + const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#')); + const head = lines.shift()!.split(',').map((h) => h.trim()); + return lines.map((line) => { + const cells = line.split(',').map((c) => c.trim()); + const o: Record = {}; + head.forEach((h, i) => (o[h] = cells[i] ?? '')); + return toRelated(o); + }); +} + +function toRelated(o: any): Related { + const num = (v: unknown) => { + const n = Number(String(v ?? '').replace(/[^0-9]/g, '')); + return Number.isFinite(n) ? n : 0; + }; + return { + keyword: String(o.relKeyword ?? o.keyword ?? '').trim(), + volumePc: num(o.monthlyPcQcCnt), + volumeMobile: num(o.monthlyMobileQcCnt), + competition: o.compIdx ? String(o.compIdx).trim() : null, + }; +} + +function main() { + const file = process.argv[2]; + const apply = process.argv.includes('--apply'); + if (!file) { console.error('사용법: tsx scripts/import-related.ts [--apply]'); process.exit(1); } + + const ds = JSON.parse(readFileSync(DATASET, 'utf8')); + const existing = new Map(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i])); + + const rows = parse(file).filter((r) => r.keyword); + let added = 0, enriched = 0, skipped = 0; + const newItems: any[] = []; + + for (const r of rows) { + const canonical = canonicalizeKeyword(r.keyword); + const norm = normalizeKeyword(canonical); + if (!norm || isBanned(canonical)) { skipped++; continue; } + const volume = r.volumePc + r.volumeMobile; + const hit = existing.get(norm); + if (hit) { + hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad'; + enriched++; + } else { + const item = { + keyword: canonical, intent: 'local', kind: 'keyword', category: '연관', + relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad', + }; + newItems.push(item); existing.set(norm, item); added++; + } + } + + console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`); + if (newItems.length) { + console.log('\n신규 예시'); + for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`); + } + if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; } + + ds.items = [...ds.items, ...newItems]; + ds.count = ds.items.length; + writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n'); + console.log(`\n✅ ${DATASET} → ${ds.count}건`); +} + +main(); diff --git a/ontology/scripts/ingest-dataset.ts b/ontology/scripts/ingest-dataset.ts new file mode 100644 index 0000000..0e50243 --- /dev/null +++ b/ontology/scripts/ingest-dataset.ts @@ -0,0 +1,103 @@ +/** + * data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. + * npx tsx scripts/ingest-dataset.ts + * + * 정책: 주기 수집 없음. 고정 데이터셋 1회 적재. + * 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고, + * 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조) + */ +import { readFileSync } from 'node:fs'; +import { createSql, toVector } from '../src/db/db'; +import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize'; +import { LocalEmbeddingProvider } from '../src/embedding/local.provider'; +import { MockEmbeddingProvider } from '../src/embedding/mock.provider'; +import { env } from '../src/config/env'; + +type Item = { keyword: string; intent: string; kind: string; category: string; relevance: number }; + +async function main() { + const sql = createSql(); + const embedder = + env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider(); + + const raw = JSON.parse(readFileSync('data/gunsan-pension-keywords.json', 'utf8')); + const items: Item[] = raw.items; + console.log(`📦 데이터셋 ${items.length}건 · 임베딩 ${embedder.name}`); + + // 1) 파일 내 어휘 중복 정리 + const byNorm = new Map(); + let banned = 0; + for (const it of items) { + const canonical = canonicalizeKeyword(it.keyword); + const norm = normalizeKeyword(it.keyword); + if (!norm || isBanned(canonical)) { banned++; continue; } + const hit = byNorm.get(norm); + if (hit) { + if (!hit.aliases.includes(canonical) && hit.item.keyword !== canonical) hit.aliases.push(canonical); + if (it.relevance > hit.item.relevance) hit.item = it; + } else { + byNorm.set(norm, { item: { ...it, keyword: canonical }, aliases: [] }); + } + } + const uniq = [...byNorm.entries()]; + console.log(` 어휘 중복제거 → ${uniq.length}건 (병합 ${items.length - uniq.length - banned}, 금칙어 ${banned})`); + + // 2) 임베딩 (배치) + const t0 = Date.now(); + const vecs = await embedder.embed(uniq.map(([, v]) => v.item.keyword), 'passage'); + console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`); + + // 3) 적재 + const region = 'kr.jeonbuk.gunsan'; + const industry = 'stay.pension'; + let inserted = 0, updated = 0; + await sql.begin(async (tx) => { + for (let i = 0; i < uniq.length; i++) { + const [norm, v] = uniq[i]; + const res = await tx>` + INSERT INTO keyword + (canonical, normalized, locale, aliases, intent, kind, category, source, + industry_id, region_id, embedding) + VALUES (${v.item.keyword}, ${norm}, 'ko-KR', ${v.aliases}, + ${v.item.intent}::keyword_intent, ${v.item.kind}, ${v.item.category}, 'dataset', + ${industry}, ${region}, ${toVector(vecs[i])}::vector) + ON CONFLICT (normalized, locale) DO UPDATE SET + canonical = EXCLUDED.canonical, aliases = EXCLUDED.aliases, intent = EXCLUDED.intent, + kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source, + embedding = EXCLUDED.embedding, updated_at = now() + RETURNING (xmax = 0) AS inserted`; + res[0]?.inserted ? inserted++ : updated++; + if (i % 100 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`); + } + }); + console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); + + // 4) 데이터셋에서 빠진 행 정리. + // upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다. + // (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다) + const wanted = uniq.map(([norm]) => norm); + const stale = await sql>` + DELETE FROM keyword + WHERE source = 'dataset' AND locale = 'ko-KR' AND NOT (normalized = ANY(${wanted})) + RETURNING canonical`; + console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제` + + (stale.length ? ` (예: ${stale.slice(0, 4).map((r) => r.canonical).join(', ')})` : '')); + + const [{ n: total }] = await sql>` + SELECT count(*)::int AS n FROM keyword WHERE source = 'dataset'`; + console.log(` 📚 사전 현재 ${total}건`); + + // 5) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만 + const near = await sql>` + SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim + FROM keyword k1 JOIN keyword k2 + ON k1.id < k2.id AND k1.embedding <=> k2.embedding < 0.02 + WHERE k1.source = 'dataset' AND k2.source = 'dataset' + ORDER BY sim DESC LIMIT 15`; + console.log(`\n🔍 벡터 근접쌍 검토 목록 (cos ≥ 0.98, 자동 병합 안 함) — 상위 ${near.length}`); + for (const n of near) console.log(` ${Number(n.sim).toFixed(4)} ${n.a} ↔ ${n.b}`); + + await sql.end(); +} + +main().catch((e) => { console.error('❌', e); process.exit(1); }); diff --git a/ontology/scripts/ingest-nationwide.ts b/ontology/scripts/ingest-nationwide.ts new file mode 100644 index 0000000..140ea41 --- /dev/null +++ b/ontology/scripts/ingest-nationwide.ts @@ -0,0 +1,105 @@ +/** + * 전국 지역별 펜션 키워드를 pgvector 에 적재한다. + * npx tsx scripts/ingest-nationwide.ts + * + * 군산 상세 데이터셋(source='dataset')과 공존시킨다. + * 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다. + */ +import { readFileSync } from 'node:fs'; +import { createSql, toVector } from '../src/db/db'; +import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; +import { LocalEmbeddingProvider } from '../src/embedding/local.provider'; +import { MockEmbeddingProvider } from '../src/embedding/mock.provider'; +import { env } from '../src/config/env'; + +const SOURCE = 'nationwide'; + +interface Item { + sido: string; region: string; regionKey: string; regionType: string; + keyword: string; kind: string; intent: string; category: string; tier: string; relevance: number; +} + +async function main() { + const sql = createSql(); + const embedder = + env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider(); + + const ds = JSON.parse(readFileSync('data/nationwide-pension-keywords.json', 'utf8')); + const items: Item[] = ds.items; + const regions = JSON.parse(readFileSync('data/regions.json', 'utf8')).regions as + Array<{ sido: string; name: string; key: string }>; + console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`); + + // 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다. + const nodes = new Map(); + for (const r of regions) { + const sidoKey = r.key.split('.').slice(0, -1).join('.'); + nodes.set(sidoKey, r.sido); + nodes.set(r.key, r.name); + } + nodes.set('kr', '대한민국'); + for (const [key, name] of [...nodes].sort((a, b) => a[0].length - b[0].length)) { + await sql`INSERT INTO region (id, path, name) VALUES (${key}, ${key}::ltree, ${name}) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`; + } + console.log(` 🗺 지역 노드 ${nodes.size}개 등록`); + + // 2) 어휘 중복 정리 — 키는 (지역, 정규화 키워드) + const byKey = new Map(); + let banned = 0; + for (const it of items) { + const canonical = canonicalizeKeyword(it.keyword); + const n = normalizeKeyword(canonical); + if (!n || isBanned(canonical)) { banned++; continue; } + const k = `${it.regionKey}|${n}`; + if (!byKey.has(k)) byKey.set(k, { item: { ...it, keyword: canonical }, norm: n }); + } + const uniq = [...byKey.values()]; + console.log(` 어휘 중복제거 → ${uniq.length}건 (금칙어 ${banned})`); + + // 3) 임베딩 + const t0 = Date.now(); + const vecs = await embedder.embed(uniq.map((u) => u.item.keyword), 'passage'); + console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`); + + // 4) 적재. + // keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면 + // 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가 + // 자연히 구분되므로 문제되지 않는다. + let inserted = 0, updated = 0; + await sql.begin(async (tx) => { + for (let i = 0; i < uniq.length; i++) { + const { item, norm } = uniq[i]; + const res = await tx>` + INSERT INTO keyword + (canonical, normalized, locale, aliases, intent, kind, category, source, + industry_id, region_id, embedding) + VALUES (${item.keyword}, ${norm}, 'ko-KR', ${[]}, + ${item.intent}::keyword_intent, ${item.kind}, ${item.category}, ${SOURCE}, + 'stay.pension', ${item.kind === 'tag' ? null : item.regionKey}, + ${toVector(vecs[i])}::vector) + ON CONFLICT (normalized, locale) DO UPDATE SET + canonical = EXCLUDED.canonical, intent = EXCLUDED.intent, kind = EXCLUDED.kind, + category = EXCLUDED.category, source = EXCLUDED.source, + region_id = COALESCE(keyword.region_id, EXCLUDED.region_id), + embedding = EXCLUDED.embedding, updated_at = now() + RETURNING (xmax = 0) AS inserted`; + res[0]?.inserted ? inserted++ : updated++; + if (i % 500 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`); + } + }); + console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); + + // 5) 이 출처 안에서만 잔여 정리 + const wanted = uniq.map((u) => u.norm); + const stale = await sql` + DELETE FROM keyword WHERE source = ${SOURCE} AND NOT (normalized = ANY(${wanted})) RETURNING id`; + console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제`); + + const counts = await sql>` + SELECT source, count(*)::int AS n FROM keyword GROUP BY source ORDER BY n DESC`; + console.log(' 📚 사전 현황: ' + counts.map((c) => `${c.source} ${c.n}`).join(' · ')); + await sql.end(); +} + +main().catch((e) => { console.error('❌', e); process.exit(1); }); diff --git a/ontology/scripts/purge-nondataset.ts b/ontology/scripts/purge-nondataset.ts new file mode 100644 index 0000000..7282318 --- /dev/null +++ b/ontology/scripts/purge-nondataset.ts @@ -0,0 +1,35 @@ +/** + * 고정 데이터셋 정책 위반분 정리. + * npx tsx scripts/purge-nondataset.ts [--apply] + * + * 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가 + * 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다. + */ +import { createSql } from '../src/db/db'; + +async function main() { + const apply = process.argv.includes('--apply'); + const sql = createSql(); + + const rows = await sql>` + SELECT source, count(*)::int AS n, (array_agg(canonical ORDER BY canonical))[1:6] AS sample + FROM keyword GROUP BY source ORDER BY n DESC`; + console.log('출처별 현황'); + for (const r of rows) console.log(` ${r.source.padEnd(10)} ${String(r.n).padStart(5)} ${r.sample.join(', ')}`); + + const doomed = await sql>` + SELECT count(*)::int AS n FROM keyword WHERE source NOT IN ('dataset', 'manual')`; + const n = doomed[0]?.n ?? 0; + if (n === 0) { console.log('\n정리 대상 없음'); await sql.end(); return; } + + if (!apply) { + console.log(`\n정리 대상 ${n}건. 실제로 지우려면 --apply 를 붙일 것.`); + await sql.end(); + return; + } + const del = await sql`DELETE FROM keyword WHERE source NOT IN ('dataset', 'manual') RETURNING id`; + console.log(`\n✅ ${del.length}건 삭제 (merchant_keyword 는 CASCADE)`); + await sql.end(); +} + +main().catch((e) => { console.error('❌', e); process.exit(1); }); diff --git a/ontology/scripts/requirements.txt b/ontology/scripts/requirements.txt new file mode 100644 index 0000000..70a4f00 --- /dev/null +++ b/ontology/scripts/requirements.txt @@ -0,0 +1,3 @@ +# 엑셀 산출 스크립트용 (scripts/export-xlsx.py, export-db-xlsx.py) +# pip3 install -r scripts/requirements.txt +openpyxl>=3.1 diff --git a/ontology/scripts/setup.sh b/ontology/scripts/setup.sh new file mode 100755 index 0000000..1ec1351 --- /dev/null +++ b/ontology/scripts/setup.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# 클론 직후 로컬 세팅 한 번에. +# npm run setup +set -euo pipefail +cd "$(dirname "$0")/.." + +step() { printf '\n\033[1m▶ %s\033[0m\n' "$1"; } + +step "사전 점검" +command -v docker >/dev/null || { echo "❌ docker 가 필요합니다"; exit 1; } +docker info >/dev/null 2>&1 || { echo "❌ Docker Desktop 을 실행해 주세요"; exit 1; } +node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' \ + || { echo "❌ Node 20 이상이 필요합니다 (현재 $(node -v))"; exit 1; } +echo " docker ok · node $(node -v)" + +step ".env 준비" +if [ -f .env ]; then echo " 이미 있음 — 건너뜀"; else cp .env.example .env; echo " .env.example → .env"; fi + +step "컨테이너 기동 (postgres+pgvector, redis)" +docker compose up -d --wait + +step "스키마 마이그레이션" +npm run --silent db:migrate + +step "기준 데이터 시드 (업종·지역·데모 업체)" +npm run --silent db:seed + +step "키워드 적재 — 군산 상세" +echo " 최초 1회 임베딩 모델을 내려받습니다 (약 120MB, 1~2분)" +npm run --silent dataset:ingest 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true + +step "키워드 적재 — 전국 54개 지역" +npm run --silent dataset:ingest-nationwide 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true + +step "완료" +cat <<'MSG' + npm start → http://localhost:3100 + http://localhost:3100/demo 매칭 콘솔 (입력창에 "스테이 머뭄") + + 엑셀 산출이 필요하면: pip3 install -r scripts/requirements.txt +MSG diff --git a/ontology/scripts/smoke.ts b/ontology/scripts/smoke.ts new file mode 100644 index 0000000..367afe7 --- /dev/null +++ b/ontology/scripts/smoke.ts @@ -0,0 +1,103 @@ +/** + * 로컬 엔드투엔드 점검 스크립트. + * npm run db:reset && npm start (다른 터미널) + * npm run smoke + */ +const BASE = process.env.BASE_URL ?? 'http://localhost:3100'; + +const j = async (method: string, path: string, body?: unknown) => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { 'content-type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${text}`); + return text ? JSON.parse(text) : null; +}; + +const h = (t: string) => console.log(`\n\x1b[1m${t}\x1b[0m`); + +async function main() { + h('0. health'); + console.log(' ', await j('GET', '/health')); + + h('1. site-1001 생성 (첫 업체 — 전부 신규)'); + const a = await j('POST', '/v1/merchants/site-1001/generate?sync=true'); + printStats(a); + + h('2. site-1002 생성 (같은 강남/미용실 — 중복제거 발동)'); + const b = await j('POST', '/v1/merchants/site-1002/generate?sync=true'); + printStats(b); + printDetails(b); + + h('3. publish 웹훅 + 표기 변형 (trigram 단계)'); + const c = await j('POST', '/v1/merchants/publish', { + externalId: 'site-1003', + name: '강남 뷰티랩', + industryId: 'beauty.hair', + regionId: 'kr.seoul.gangnam', + description: '강남 미용실. 염색 전문.', + profile: { services: ['뿌리염색약', '여성펌'], features: ['주차가능'] }, + sync: true, + }); + printStats(c.generation); + printDetails(c.generation); + + h('4. SEO payload'); + const seo = await j('GET', '/v1/sites/site-1001/seo?limit=8'); + console.log(' title :', seo.title); + console.log(' description:', seo.description); + console.log(' keywords :', seo.keywords.join(', ')); + + h('5. AEO payload'); + const aeo = await j('GET', '/v1/sites/site-1001/aeo?limit=3'); + for (const f of aeo.faqs) console.log(` Q. ${f.question}\n A. ${f.answer}`); + + h('6. 의미 기반 키워드 검색'); + const found = await j('POST', '/v1/keywords/search', { query: '강남 미용실 예약하고 싶어요', limit: 5 }); + for (const r of found) console.log(` ${r.score.toFixed(3)} ${r.canonical} (${r.intent}, ${r.usage_count}개 업체)`); + + h('7. 성과 피드백 → 저성과 강등'); + console.log( + ' ', + await j('POST', '/v1/sites/site-1001/performance', { + items: [ + { keyword: '강남 미용실 후기', impressions: 500, clicks: 0 }, + { keyword: '강남 미용실', impressions: 300, clicks: 40 }, + ], + }), + ); + + h('8. 비동기 큐 (BullMQ)'); + console.log(' ', await j('POST', '/v1/merchants/site-2001/generate')); + for (let i = 0; i < 30; i++) { + const s = await j('GET', '/v1/sites/site-2001/seo?limit=5'); + if (s.keywords.length) { + console.log(' 워커 처리 완료 →', s.keywords.join(', ')); + break; + } + await new Promise((r) => setTimeout(r, 500)); + } + + console.log('\n✅ smoke 완료'); +} + +function printStats(s: any) { + console.log( + ` 후보 ${s.candidates} → 신규 ${s.created} / 중복(정확 ${s.matchedExact}, 표기 ${s.matchedTrigram}, 의미 ${s.matchedVector})` + + ` / 차단 ${s.rejected} / 연결 ${s.linked} / QA ${s.qaCreated} (${s.durationMs}ms)`, + ); +} + +function printDetails(s: any) { + for (const d of s.details ?? []) { + const sim = d.similarity != null ? ` (sim=${d.similarity.toFixed(3)} → '${d.matchedTo}')` : ''; + console.log(` ${d.action.padEnd(16)} ${d.candidate}${sim}`); + } +} + +main().catch((e) => { + console.error('\n❌', e.message); + process.exit(1); +}); diff --git a/ontology/src/app.module.ts b/ontology/src/app.module.ts new file mode 100644 index 0000000..477c904 --- /dev/null +++ b/ontology/src/app.module.ts @@ -0,0 +1,36 @@ +import { BullModule } from '@nestjs/bullmq'; +import { Controller, Get, Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { env } from './config/env'; +import { DbModule } from './db/db.module'; +import { EmbeddingModule } from './embedding/embedding.module'; +import { GenerationModule } from './generation/generation.module'; +import { MerchantsHttpModule } from './merchants/merchants.controller.module'; +import { ServingModule } from './serving/serving.module'; + +@Controller() +class HealthController { + @Get('health') + health() { + return { + status: 'ok', + llmProvider: env.llm.provider, + embeddingProvider: env.embedding.provider, + ts: new Date().toISOString(), + }; + } +} + +@Module({ + imports: [ + DbModule, + EmbeddingModule, + ScheduleModule.forRoot(), + BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }), + GenerationModule, + MerchantsHttpModule, + ServingModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/ontology/src/config/env.ts b/ontology/src/config/env.ts new file mode 100644 index 0000000..bd0c19c --- /dev/null +++ b/ontology/src/config/env.ts @@ -0,0 +1,34 @@ +import 'dotenv/config'; + +const num = (v: string | undefined, d: number) => (v === undefined || v === '' ? d : Number(v)); + +export const env = { + port: num(process.env.PORT, 3100), + databaseUrl: process.env.DATABASE_URL ?? 'postgres://ontology:ontology@localhost:55432/ontology', + redis: { + host: process.env.REDIS_HOST ?? 'localhost', + port: num(process.env.REDIS_PORT, 56379), + }, + llm: { + provider: (process.env.LLM_PROVIDER ?? 'mock') as 'mock' | 'openai', + apiKey: process.env.OPENAI_API_KEY ?? '', + model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini', + embeddingModel: process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small', + }, + embedding: { + provider: (process.env.EMBEDDING_PROVIDER ?? 'local') as 'mock' | 'local' | 'openai', + localModel: process.env.EMBEDDING_LOCAL_MODEL ?? 'Xenova/multilingual-e5-small', + }, + dedup: { + cosineThreshold: num(process.env.DEDUP_COSINE_THRESHOLD, 0.99), + trigramThreshold: num(process.env.DEDUP_TRIGRAM_THRESHOLD, 0.6), + candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20), + }, + generation: { + targetKeywords: num(process.env.GENERATION_TARGET_KEYWORDS, 15), + refreshIntervalDays: num(process.env.REFRESH_INTERVAL_DAYS, 30), + }, +} as const; + +export const EMBEDDING_DIM = 384; +export const PROMPT_VERSION = 'kw-v1'; diff --git a/ontology/src/db/db.module.ts b/ontology/src/db/db.module.ts new file mode 100644 index 0000000..5e3018d --- /dev/null +++ b/ontology/src/db/db.module.ts @@ -0,0 +1,16 @@ +import { Global, Module, OnModuleDestroy } from '@nestjs/common'; +import { createSql, Sql } from './db'; + +export const PG = Symbol('PG'); + +@Global() +@Module({ + providers: [{ provide: PG, useFactory: () => createSql() }], + exports: [PG], +}) +export class DbModule implements OnModuleDestroy { + constructor() {} + async onModuleDestroy() {} +} + +export type { Sql }; diff --git a/ontology/src/db/db.ts b/ontology/src/db/db.ts new file mode 100644 index 0000000..e16275d --- /dev/null +++ b/ontology/src/db/db.ts @@ -0,0 +1,17 @@ +import postgres from 'postgres'; +import { env } from '../config/env'; + +export type Sql = postgres.Sql<{}>; + +export const createSql = (): Sql => + postgres(env.databaseUrl, { + max: 10, + // pgvector 컬럼은 텍스트로 주고받는다 ('[0.1,0.2,...]') + transform: { undefined: null }, + }); + +/** number[] -> pgvector 리터럴 */ +export const toVector = (v: number[]): string => `[${v.join(',')}]`; + +/** postgres.js 의 JSONValue 타입 제약 우회용 캐스트 */ +export const asJson = (v: unknown) => v as Parameters[0]; diff --git a/ontology/src/db/migrate.ts b/ontology/src/db/migrate.ts new file mode 100644 index 0000000..a699b21 --- /dev/null +++ b/ontology/src/db/migrate.ts @@ -0,0 +1,24 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createSql } from './db'; + +async function main() { + const sql = createSql(); + const dir = join(process.cwd(), 'drizzle'); + const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort(); + + for (const file of files) { + const ddl = readFileSync(join(dir, file), 'utf8'); + process.stdout.write(`▶ applying ${file} ... `); + await sql.unsafe(ddl); + process.stdout.write('done\n'); + } + + await sql.end(); + console.log('✅ migration complete'); +} + +main().catch((e) => { + console.error('❌ migration failed:', e); + process.exit(1); +}); diff --git a/ontology/src/db/seed.ts b/ontology/src/db/seed.ts new file mode 100644 index 0000000..82a826f --- /dev/null +++ b/ontology/src/db/seed.ts @@ -0,0 +1,119 @@ +import { asJson, createSql } from './db'; + +const industries = [ + ['beauty', 'beauty', '뷰티'], + ['beauty.hair', 'beauty.hair', '미용실'], + ['beauty.nail', 'beauty.nail', '네일샵'], + ['food', 'food', '음식점'], + ['food.korean', 'food.korean', '한식당'], + ['health', 'health', '의료'], + ['health.dental', 'health.dental', '치과'], + ['stay', 'stay', '숙박'], + ['stay.pension', 'stay.pension', '펜션'], +]; + +const regions = [ + ['kr', 'kr', '대한민국'], + ['kr.seoul', 'kr.seoul', '서울'], + ['kr.seoul.gangnam', 'kr.seoul.gangnam', '강남'], + ['kr.seoul.mapo', 'kr.seoul.mapo', '마포'], + ['kr.busan', 'kr.busan', '부산'], + ['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'], + ['kr.jeonbuk', 'kr.jeonbuk', '전북'], + ['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'], +]; + +const merchants = [ + { + externalId: 'site-1001', + name: '레브살롱', + industryId: 'beauty.hair', + regionId: 'kr.seoul.gangnam', + description: '강남역 3번 출구 앞 프라이빗 헤어살롱. 1:1 디자이너 전담 시스템.', + siteUrl: 'https://rev-salon.example.com', + profile: { + services: ['남자 커트', '여성 펌', '뿌리염색', '두피 클리닉'], + features: ['주차 가능', '심야 영업', '예약제'], + priceRange: '30,000~120,000원', + }, + }, + { + externalId: 'site-1002', + name: '헤어랩 강남점', + industryId: 'beauty.hair', + regionId: 'kr.seoul.gangnam', + description: '강남 대형 헤어샵. 염색과 클리닉 전문.', + siteUrl: 'https://hairlab.example.com', + profile: { + services: ['뿌리 염색', '여성 펌', '두피클리닉'], + features: ['주차가능', '단체 예약'], + priceRange: '25,000~150,000원', + }, + }, + { + externalId: 'site-2001', + name: '해운대 소담한상', + industryId: 'food.korean', + regionId: 'kr.busan.haeundae', + description: '해운대 해변 인근 한정식집. 제철 해산물 코스 제공.', + siteUrl: 'https://sodam.example.com', + profile: { + services: ['한정식 코스', '점심 특선', '단체 예약'], + features: ['오션뷰', '룸 완비', '발렛파킹'], + priceRange: '25,000~80,000원', + }, + }, + { + // 실제 업체. 공개 정보로 확인된 항목만 넣는다. + // 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인 + // 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것 + externalId: 'site-3001', + name: '스테이머뭄', + industryId: 'stay.pension', + regionId: 'kr.jeonbuk.gunsan', + description: + '군산 원도심 말랭이마을 옆에 자리한 독채 스테이. A동·B동 두 채를 통째로 쓰며 기준 2인, 최대 4인.', + siteUrl: 'https://www.instagram.com/staymeomoom/', + profile: { + services: ['독채 대여', 'A동', 'B동'], + features: ['독채', '프라이빗', '2인 기준', '최대 4인', '원도심', '감성숙소'], + audiences: ['커플', '친구', '가족', '혼자'], + nearby: ['말랭이마을', '신흥동 일본식가옥', '동국사', '초원사진관', '이성당', + '경암동 철길마을', '근대역사박물관', '월명공원', '시간여행마을'], + address: '전북특별자치도 군산시 절골길 18 (신흥동)', + capacity: { standard: 2, max: 4 }, + buildings: 2, + unverified: ['가격', '바베큐', '스파', '주차', '애견동반'], + }, + }, +]; + +async function main() { + const sql = createSql(); + + for (const [id, path, name] of industries) { + await sql`INSERT INTO industry (id, path, name) VALUES (${id}, ${path}::ltree, ${name}) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`; + } + for (const [id, path, name] of regions) { + await sql`INSERT INTO region (id, path, name) VALUES (${id}, ${path}::ltree, ${name}) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`; + } + for (const m of merchants) { + await sql` + INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url) + VALUES (${m.externalId}, ${m.name}, ${m.industryId}, ${m.regionId}, + ${m.description}, ${sql.json(asJson(m.profile))}, ${m.siteUrl}) + ON CONFLICT (external_id) DO UPDATE SET + name = EXCLUDED.name, description = EXCLUDED.description, + profile = EXCLUDED.profile, updated_at = now()`; + } + + await sql.end(); + console.log(`✅ seed: industry=${industries.length} region=${regions.length} merchant=${merchants.length}`); +} + +main().catch((e) => { + console.error('❌ seed failed:', e); + process.exit(1); +}); diff --git a/ontology/src/embedding/embedding.module.ts b/ontology/src/embedding/embedding.module.ts new file mode 100644 index 0000000..b24c6f0 --- /dev/null +++ b/ontology/src/embedding/embedding.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { env } from '../config/env'; +import { LocalEmbeddingProvider } from './local.provider'; +import { MockEmbeddingProvider } from './mock.provider'; +import { OpenAiEmbeddingProvider } from './openai.provider'; +import { EmbeddingProvider } from './types'; + +const IMPL = { + local: LocalEmbeddingProvider, + openai: OpenAiEmbeddingProvider, + mock: MockEmbeddingProvider, +} as const; + +@Global() +@Module({ + providers: [{ provide: EmbeddingProvider, useClass: IMPL[env.embedding.provider] }], + exports: [EmbeddingProvider], +}) +export class EmbeddingModule {} diff --git a/ontology/src/embedding/local.provider.ts b/ontology/src/embedding/local.provider.ts new file mode 100644 index 0000000..07b1dbb --- /dev/null +++ b/ontology/src/embedding/local.provider.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EMBEDDING_DIM, env } from '../config/env'; +import { EmbedKind, EmbeddingProvider } from './types'; + +/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */ +const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise; + +/** + * 로컬 multilingual-e5-small (384차원, onnxruntime CPU). + * 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다. + */ +@Injectable() +export class LocalEmbeddingProvider extends EmbeddingProvider { + readonly name = 'local:multilingual-e5-small'; + readonly dimensions = EMBEDDING_DIM; + private readonly logger = new Logger(LocalEmbeddingProvider.name); + private extractor: any | null = null; + private loading: Promise | null = null; + + private async pipe() { + if (this.extractor) return this.extractor; + if (!this.loading) { + this.loading = (async () => { + const t0 = Date.now(); + const { pipeline } = await esmImport('@huggingface/transformers'); + const fe = await pipeline('feature-extraction', env.embedding.localModel); + this.logger.log(`model ready: ${env.embedding.localModel} (${Date.now() - t0}ms)`); + this.extractor = fe; + return fe; + })(); + } + return this.loading; + } + + async embed(texts: string[], kind: EmbedKind = 'passage'): Promise { + if (texts.length === 0) return []; + const fe = await this.pipe(); + const prefixed = texts.map((t) => `${kind}: ${t}`); + const out: number[][] = []; + const BATCH = 64; + for (let i = 0; i < prefixed.length; i += BATCH) { + const slice = prefixed.slice(i, i + BATCH); + const res = await fe(slice, { pooling: 'mean', normalize: true }); + out.push(...(res.tolist() as number[][])); + } + return out; + } +} diff --git a/ontology/src/embedding/mock.provider.ts b/ontology/src/embedding/mock.provider.ts new file mode 100644 index 0000000..2d77849 --- /dev/null +++ b/ontology/src/embedding/mock.provider.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { EMBEDDING_DIM } from '../config/env'; +import { hashEmbedding } from '../llm/mock.provider'; +import { EmbedKind, EmbeddingProvider } from './types'; + +/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */ +@Injectable() +export class MockEmbeddingProvider extends EmbeddingProvider { + readonly name = 'mock:bigram-hash'; + readonly dimensions = EMBEDDING_DIM; + async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise { + return texts.map((t) => hashEmbedding(t, EMBEDDING_DIM)); + } +} diff --git a/ontology/src/embedding/openai.provider.ts b/ontology/src/embedding/openai.provider.ts new file mode 100644 index 0000000..f61d21c --- /dev/null +++ b/ontology/src/embedding/openai.provider.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import OpenAI from 'openai'; +import { EMBEDDING_DIM, env } from '../config/env'; +import { EmbedKind, EmbeddingProvider } from './types'; + +@Injectable() +export class OpenAiEmbeddingProvider extends EmbeddingProvider { + readonly name = `openai:${env.llm.embeddingModel}`; + readonly dimensions = EMBEDDING_DIM; + private readonly client = new OpenAI({ apiKey: env.llm.apiKey }); + + async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise { + if (texts.length === 0) return []; + const res = await this.client.embeddings.create({ + model: env.llm.embeddingModel, + input: texts, + dimensions: EMBEDDING_DIM, // 스키마와 차원을 맞춘다 + }); + return res.data.map((d) => d.embedding as number[]); + } +} diff --git a/ontology/src/embedding/types.ts b/ontology/src/embedding/types.ts new file mode 100644 index 0000000..31dc8b5 --- /dev/null +++ b/ontology/src/embedding/types.ts @@ -0,0 +1,8 @@ +/** e5 계열은 query 와 passage 를 비대칭으로 인코딩한다 — 검색 품질에 직접 영향. */ +export type EmbedKind = 'query' | 'passage'; + +export abstract class EmbeddingProvider { + abstract readonly name: string; + abstract readonly dimensions: number; + abstract embed(texts: string[], kind?: EmbedKind): Promise; +} diff --git a/ontology/src/generation/generation.module.ts b/ontology/src/generation/generation.module.ts new file mode 100644 index 0000000..7ff8dce --- /dev/null +++ b/ontology/src/generation/generation.module.ts @@ -0,0 +1,20 @@ +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; +import { KeywordsModule } from '../keywords/keywords.module'; +import { LlmModule } from '../llm/llm.module'; +import { MerchantsModule } from '../merchants/merchants.module'; +import { GenerationProcessor } from './generation.processor'; +import { GENERATION_QUEUE, GenerationQueue } from './generation.queue'; +import { GenerationService } from './generation.service'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: GENERATION_QUEUE }), + LlmModule, + KeywordsModule, + MerchantsModule, + ], + providers: [GenerationService, GenerationQueue, GenerationProcessor], + exports: [GenerationService, GenerationQueue], +}) +export class GenerationModule {} diff --git a/ontology/src/generation/generation.processor.ts b/ontology/src/generation/generation.processor.ts new file mode 100644 index 0000000..10dba6a --- /dev/null +++ b/ontology/src/generation/generation.processor.ts @@ -0,0 +1,21 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { GenerationService } from './generation.service'; +import { GENERATION_QUEUE, GenerationJob } from './generation.queue'; + +@Processor(GENERATION_QUEUE, { concurrency: 2 }) +export class GenerationProcessor extends WorkerHost { + private readonly logger = new Logger(GenerationProcessor.name); + + constructor(private readonly generation: GenerationService) { + super(); + } + + async process(job: Job) { + const { merchantId, trigger } = job.data; + this.logger.log(`processing ${job.id} (${trigger})`); + const stats = await this.generation.runForMerchant(merchantId, trigger); + return { ...stats, details: undefined }; + } +} diff --git a/ontology/src/generation/generation.queue.ts b/ontology/src/generation/generation.queue.ts new file mode 100644 index 0000000..bd90239 --- /dev/null +++ b/ontology/src/generation/generation.queue.ts @@ -0,0 +1,50 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Queue } from 'bullmq'; +import { env } from '../config/env'; +import { MerchantsService } from '../merchants/merchants.service'; +import { GenerationTrigger } from './generation.service'; + +export const GENERATION_QUEUE = 'keyword-generation'; + +export interface GenerationJob { + merchantId: string; + trigger: GenerationTrigger; +} + +@Injectable() +export class GenerationQueue { + private readonly logger = new Logger(GenerationQueue.name); + + constructor( + @InjectQueue(GENERATION_QUEUE) private readonly queue: Queue, + private readonly merchants: MerchantsService, + ) {} + + async enqueue(merchantId: string, trigger: GenerationTrigger): Promise { + // 짧은 시간 내 같은 업체가 여러 번 발행돼도 한 번만 처리 (60초 dedupe 창) + const job = await this.queue.add( + 'generate', + { merchantId, trigger }, + { + deduplication: { id: `${merchantId}-${trigger}`, ttl: 60_000 }, + removeOnComplete: 100, + removeOnFail: 500, + attempts: 3, + backoff: { type: 'exponential', delay: 5_000 }, + }, + ); + return String(job.id); + } + + /** 주기 리프레시: 매일 03:00, N일 지난 업체를 큐에 적재 */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async scheduleRefresh() { + const stale = await this.merchants.findStale(env.generation.refreshIntervalDays, 200); + for (const m of stale) { + await this.enqueue(m.id, 'scheduled'); + } + if (stale.length) this.logger.log(`scheduled refresh queued: ${stale.length} merchants`); + } +} diff --git a/ontology/src/generation/generation.service.ts b/ontology/src/generation/generation.service.ts new file mode 100644 index 0000000..b866415 --- /dev/null +++ b/ontology/src/generation/generation.service.ts @@ -0,0 +1,216 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { PG } from '../db/db.module'; +import { asJson, Sql, toVector } from '../db/db'; +import { env, PROMPT_VERSION } from '../config/env'; +import { DedupAction, DedupService } from '../keywords/dedup.service'; +import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize'; +import { EmbeddingProvider } from '../embedding/types'; +import { LlmProvider, MerchantContext } from '../llm/types'; +import { MerchantsService } from '../merchants/merchants.service'; + +export type GenerationTrigger = 'published' | 'scheduled' | 'manual'; + +export interface GenerationStats { + runId: string; + merchantId: string; + merchantName: string; + provider: string; + model: string; + candidates: number; + created: number; + matchedExact: number; + matchedTrigram: number; + matchedVector: number; + rejected: number; + linked: number; + qaCreated: number; + durationMs: number; + details: Array<{ candidate: string; action: DedupAction; matchedTo?: string; similarity?: number }>; +} + +@Injectable() +export class GenerationService { + private readonly logger = new Logger(GenerationService.name); + + constructor( + @Inject(PG) private readonly sql: Sql, + private readonly merchants: MerchantsService, + private readonly llm: LlmProvider, + private readonly embedder: EmbeddingProvider, + private readonly dedup: DedupService, + ) {} + + async runForMerchant( + idOrExternalId: string, + trigger: GenerationTrigger = 'manual', + targetCount = env.generation.targetKeywords, + ): Promise { + const startedAt = Date.now(); + const merchant = await this.merchants.findWithTaxonomy(idOrExternalId); + + const runRows = await this.sql>` + INSERT INTO generation_run (merchant_id, provider, model, prompt_version, trigger, status, input) + VALUES (${merchant.id}, ${this.llm.name}, ${this.llm.model}, ${PROMPT_VERSION}, + ${trigger}, 'running', ${this.sql.json(asJson({ externalId: merchant.external_id }))}) + RETURNING id`; + const runId = runRows[0].id; + + try { + const existing = await this.existingKeywordsFor(merchant.id, merchant.industry_id); + const ctx: MerchantContext = { + externalId: merchant.external_id, + name: merchant.name, + description: merchant.description, + industryName: merchant.industry_name, + industryPath: merchant.industry_path, + regionName: merchant.region_name, + regionPath: merchant.region_path, + profile: merchant.profile ?? {}, + existingKeywords: existing, + targetCount, + }; + + const output = await this.llm.generate(ctx); + + // 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다) + const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword)); + const embeddings = texts.length ? await this.embedder.embed(texts, 'passage') : []; + + const stats: GenerationStats = { + runId, + merchantId: merchant.id, + merchantName: merchant.name, + provider: this.llm.name, + model: output.model, + candidates: output.keywords.length, + created: 0, + matchedExact: 0, + matchedTrigram: 0, + matchedVector: 0, + rejected: 0, + linked: 0, + qaCreated: 0, + durationMs: 0, + details: [], + }; + + for (let i = 0; i < output.keywords.length; i++) { + const cand = output.keywords[i]; + const result = await this.dedup.resolve({ + raw: cand.keyword, + intent: cand.intent, + embedding: embeddings[i], + locale: 'ko-KR', + industryId: merchant.industry_id, + regionId: merchant.region_id, + }); + + stats.details.push({ + candidate: canonicalizeKeyword(cand.keyword), + action: result.action, + matchedTo: result.matchedTo, + similarity: result.similarity, + }); + + switch (result.action) { + case 'created': stats.created++; break; + case 'matched_exact': stats.matchedExact++; break; + case 'matched_trigram': stats.matchedTrigram++; break; + case 'matched_vector': stats.matchedVector++; break; + case 'rejected_banned': stats.rejected++; break; + } + + if (result.keywordId) { + const linked = await this.linkKeyword(merchant.id, result.keywordId, cand.relevance, cand.rationale); + if (linked) stats.linked++; + } + } + + stats.qaCreated = await this.upsertQaPairs(merchant.id, output.qaPairs); + await this.merchants.markGenerated(merchant.id); + stats.durationMs = Date.now() - startedAt; + + await this.sql` + UPDATE generation_run + SET status = 'succeeded', + output = ${this.sql.json(asJson(output))}, + stats = ${this.sql.json(asJson({ ...stats, details: undefined }))}, + finished_at = now() + WHERE id = ${runId}`; + + this.logger.log( + `[${merchant.name}] cand=${stats.candidates} new=${stats.created} ` + + `dup(exact/trg/vec)=${stats.matchedExact}/${stats.matchedTrigram}/${stats.matchedVector} ` + + `rejected=${stats.rejected} qa=${stats.qaCreated} ${stats.durationMs}ms`, + ); + return stats; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.sql` + UPDATE generation_run + SET status = 'failed', error = ${message}, finished_at = now() + WHERE id = ${runId}`; + throw err; + } + } + + /** 프롬프트에 넣을 "이미 보유한 키워드": 자기 것 + 같은 업종에서 많이 쓰는 것 */ + private async existingKeywordsFor(merchantId: string, industryId: string | null): Promise { + const rows = await this.sql>` + SELECT DISTINCT k.canonical + FROM keyword k + LEFT JOIN merchant_keyword mk ON mk.keyword_id = k.id AND mk.merchant_id = ${merchantId} + WHERE mk.merchant_id IS NOT NULL + OR (${industryId}::text IS NOT NULL AND k.industry_id = ${industryId} AND k.usage_count > 0) + ORDER BY k.canonical + LIMIT 100`; + return rows.map((r) => r.canonical); + } + + private async linkKeyword( + merchantId: string, + keywordId: string, + relevance: number, + rationale: string, + ): Promise { + const status = relevance >= 0.5 ? 'active' : 'candidate'; + const rows = await this.sql>` + INSERT INTO merchant_keyword (merchant_id, keyword_id, relevance, source, status, rationale) + VALUES (${merchantId}, ${keywordId}, ${relevance}, 'llm', ${status}, ${rationale}) + ON CONFLICT (merchant_id, keyword_id) DO UPDATE SET + relevance = GREATEST(merchant_keyword.relevance, EXCLUDED.relevance), + rationale = COALESCE(EXCLUDED.rationale, merchant_keyword.rationale), + updated_at = now() + RETURNING (xmax = 0) AS inserted`; + + if (rows[0]?.inserted) { + await this.sql`UPDATE keyword SET usage_count = usage_count + 1 WHERE id = ${keywordId}`; + return true; + } + return false; + } + + private async upsertQaPairs( + merchantId: string, + pairs: Array<{ question: string; answer: string }>, + ): Promise { + if (pairs.length === 0) return 0; + const embeddings = await this.embedder.embed(pairs.map((p) => p.question), 'passage'); + let created = 0; + + for (let i = 0; i < pairs.length; i++) { + const p = pairs[i]; + const nq = normalizeKeyword(p.question); + if (!nq) continue; + const rows = await this.sql>` + INSERT INTO qa_pair (merchant_id, question, answer, normalized_question, embedding) + VALUES (${merchantId}, ${canonicalizeKeyword(p.question)}, ${p.answer.trim()}, + ${nq}, ${toVector(embeddings[i])}::vector) + ON CONFLICT (merchant_id, normalized_question) DO UPDATE SET + answer = EXCLUDED.answer, updated_at = now() + RETURNING (xmax = 0) AS inserted`; + if (rows[0]?.inserted) created++; + } + return created; + } +} diff --git a/ontology/src/keywords/dedup.service.ts b/ontology/src/keywords/dedup.service.ts new file mode 100644 index 0000000..2c2089d --- /dev/null +++ b/ontology/src/keywords/dedup.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { env } from '../config/env'; +import { KeywordIntent } from '../llm/types'; +import { KeywordRepository } from './keyword.repository'; +import { canonicalizeKeyword, isBanned, normalizeKeyword } from './normalize'; + +export type DedupAction = + | 'created' // 새 키워드 + | 'matched_exact' // 1단계: 정규화 해시 일치 + | 'matched_trigram' // 2단계: 표기 변형/오타 + | 'matched_vector' // 3단계: 의미 중복 → alias 흡수 + | 'rejected_banned'; // 금칙어 + +export interface DedupResult { + action: DedupAction; + keywordId: string | null; + canonical: string; + matchedTo?: string; + similarity?: number; +} + +export interface ResolveInput { + raw: string; + intent: KeywordIntent; + embedding: number[]; + locale: string; + industryId: string | null; + regionId: string | null; +} + +/** + * 4단계 계단식 중복제거. + * 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다. + */ +@Injectable() +export class DedupService { + private readonly logger = new Logger(DedupService.name); + + constructor(private readonly repo: KeywordRepository) {} + + async resolve(input: ResolveInput): Promise { + const canonical = canonicalizeKeyword(input.raw); + const normalized = normalizeKeyword(input.raw); + + // 0단계 — 금칙어/과장광고 차단 + if (!normalized || isBanned(canonical)) { + return { action: 'rejected_banned', keywordId: null, canonical }; + } + + // 1단계 — 정규화 완전 일치 (공백/구두점 차이 흡수) + const exact = await this.repo.findByNormalized(normalized, input.locale); + if (exact) { + await this.repo.absorbAlias(exact.id, canonical); + return { + action: 'matched_exact', + keywordId: exact.id, + canonical: exact.canonical, + matchedTo: exact.canonical, + similarity: 1, + }; + } + + // 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정 + // + // 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다. + // 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936, + // '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다. + // 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다. + // 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다. + const candidates = await this.repo.findDedupCandidates( + input.embedding, + normalized, + input.locale, + env.dedup.candidateLimit, + ); + + const trigramHit = candidates.find((c) => c.trg >= env.dedup.trigramThreshold); + if (trigramHit) { + await this.repo.absorbAlias(trigramHit.id, canonical); + return { + action: 'matched_trigram', + keywordId: trigramHit.id, + canonical: trigramHit.canonical, + matchedTo: trigramHit.canonical, + similarity: trigramHit.trg, + }; + } + + const best = candidates[0]; + if (best && best.cosine >= env.dedup.cosineThreshold) { + await this.repo.absorbAlias(best.id, canonical); + return { + action: 'matched_vector', + keywordId: best.id, + canonical: best.canonical, + matchedTo: best.canonical, + similarity: best.cosine, + }; + } + + // 4단계 — 신규 등록 + const created = await this.repo.insert({ + canonical, + normalized, + locale: input.locale, + intent: input.intent, + embedding: input.embedding, + industryId: input.industryId, + regionId: input.regionId, + }); + return { action: 'created', keywordId: created.id, canonical: created.canonical }; + } +} diff --git a/ontology/src/keywords/keyword.repository.ts b/ontology/src/keywords/keyword.repository.ts new file mode 100644 index 0000000..56a5104 --- /dev/null +++ b/ontology/src/keywords/keyword.repository.ts @@ -0,0 +1,124 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PG } from '../db/db.module'; +import { Sql, toVector } from '../db/db'; +import { KeywordIntent } from '../llm/types'; + +export interface KeywordRow { + id: string; + canonical: string; + normalized: string; + aliases: string[]; + intent: KeywordIntent; + usage_count: number; +} + +export interface CandidateRow { + id: string; + canonical: string; + normalized: string; + cosine: number; + trg: number; +} + +@Injectable() +export class KeywordRepository { + constructor(@Inject(PG) private readonly sql: Sql) {} + + async findByNormalized(normalized: string, locale: string): Promise { + const rows = await this.sql` + SELECT id, canonical, normalized, aliases, intent, usage_count + FROM keyword + WHERE normalized = ${normalized} AND locale = ${locale} + LIMIT 1`; + return rows[0] ?? null; + } + + /** + * 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. + * 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다. + */ + async findDedupCandidates( + embedding: number[], + normalized: string, + locale: string, + limit: number, + ): Promise { + const vec = toVector(embedding); + const rows = await this.sql` + ( + SELECT id, canonical, normalized, + 1 - (embedding <=> ${vec}::vector) AS cosine, + similarity(normalized, ${normalized}) AS trg + FROM keyword + WHERE locale = ${locale} + AND embedding IS NOT NULL + AND normalized % ${normalized} + ORDER BY trg DESC + LIMIT ${limit} + ) + UNION ALL + ( + SELECT id, canonical, normalized, + 1 - (embedding <=> ${vec}::vector) AS cosine, + 0::real AS trg + FROM keyword + WHERE locale = ${locale} + AND embedding IS NOT NULL + ORDER BY embedding <=> ${vec}::vector + LIMIT ${limit} + )`; + + const best = new Map(); + for (const r of rows) { + const prev = best.get(r.id); + if (!prev || r.trg > prev.trg) best.set(r.id, { ...r, cosine: Number(r.cosine), trg: Number(r.trg) }); + } + return [...best.values()].sort((a, b) => b.cosine - a.cosine); + } + + async insert(input: { + canonical: string; + normalized: string; + locale: string; + intent: KeywordIntent; + embedding: number[]; + industryId: string | null; + regionId: string | null; + }): Promise { + const rows = await this.sql` + INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count) + VALUES (${input.canonical}, ${input.normalized}, ${input.locale}, ${input.intent}, + ${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0) + ON CONFLICT (normalized, locale) DO UPDATE SET updated_at = now() + RETURNING id, canonical, normalized, aliases, intent, usage_count`; + return rows[0]; + } + + /** 표기 변형을 기존 키워드에 흡수 (롱테일 검색어 보존) */ + async absorbAlias(keywordId: string, alias: string): Promise { + await this.sql` + UPDATE keyword + SET aliases = ( + SELECT ARRAY(SELECT DISTINCT unnest(aliases || ARRAY[${alias}]::text[])) + ), + updated_at = now() + WHERE id = ${keywordId} + AND NOT (${alias} = ANY(aliases)) + AND canonical <> ${alias}`; + } + + async bumpUsage(keywordId: string): Promise { + await this.sql` + UPDATE keyword SET usage_count = usage_count + 1, updated_at = now() WHERE id = ${keywordId}`; + } + + async searchByVector(embedding: number[], locale: string, limit: number) { + const vec = toVector(embedding); + return this.sql>` + SELECT id, canonical, intent, usage_count, 1 - (embedding <=> ${vec}::vector) AS score + FROM keyword + WHERE locale = ${locale} AND embedding IS NOT NULL + ORDER BY embedding <=> ${vec}::vector + LIMIT ${limit}`; + } +} diff --git a/ontology/src/keywords/keywords.module.ts b/ontology/src/keywords/keywords.module.ts new file mode 100644 index 0000000..7962c0e --- /dev/null +++ b/ontology/src/keywords/keywords.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DedupService } from './dedup.service'; +import { KeywordRepository } from './keyword.repository'; + +@Module({ + providers: [KeywordRepository, DedupService], + exports: [KeywordRepository, DedupService], +}) +export class KeywordsModule {} diff --git a/ontology/src/keywords/normalize.ts b/ontology/src/keywords/normalize.ts new file mode 100644 index 0000000..c6ad2fe --- /dev/null +++ b/ontology/src/keywords/normalize.ts @@ -0,0 +1,33 @@ +/** + * 중복 판정용 정규화. + * NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거. + * "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다. + */ +const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g; +const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g; + +export function normalizeKeyword(raw: string): string { + return raw + .normalize('NFKC') + .toLowerCase() + .replace(ZERO_WIDTH, '') + .replace(PUNCT, '') + .replace(/\s+/g, ''); +} + +/** 표시용 정리: 앞뒤/중복 공백만 정리하고 원문 표기는 보존 */ +export function canonicalizeKeyword(raw: string): string { + return raw.normalize('NFKC').replace(ZERO_WIDTH, '').replace(/\s+/g, ' ').trim(); +} + +/** 과장광고·금칙 표현 필터 (광고심의 리스크 차단) */ +const BANNED = [ + '최고', '1위', '일등', '넘버원', 'no.1', '100%', '무조건', '완치', '부작용없', + '영구', '평생보장', '유일한', '최저가보장', '전국최대', +]; +const BANNED_NORMALIZED = BANNED.map(normalizeKeyword); + +export function isBanned(text: string): boolean { + const n = normalizeKeyword(text); + return BANNED_NORMALIZED.some((b) => b.length > 0 && n.includes(b)); +} diff --git a/ontology/src/llm/llm.module.ts b/ontology/src/llm/llm.module.ts new file mode 100644 index 0000000..a8daea4 --- /dev/null +++ b/ontology/src/llm/llm.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { env } from '../config/env'; +import { MockLlmProvider } from './mock.provider'; +import { OpenAiLlmProvider } from './openai.provider'; +import { LlmProvider } from './types'; + +@Module({ + providers: [ + { + provide: LlmProvider, + useClass: env.llm.provider === 'openai' ? OpenAiLlmProvider : MockLlmProvider, + }, + ], + exports: [LlmProvider], +}) +export class LlmModule {} diff --git a/ontology/src/llm/mock.provider.ts b/ontology/src/llm/mock.provider.ts new file mode 100644 index 0000000..7d28e24 --- /dev/null +++ b/ontology/src/llm/mock.provider.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { EMBEDDING_DIM } from '../config/env'; +import { normalizeKeyword } from '../keywords/normalize'; +import { + GenerationOutput, + KeywordCandidate, + KeywordIntent, + LlmProvider, + MerchantContext, + QaCandidate, +} from './types'; + +/** + * API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. + * + * embed(): 문자 bigram 해싱 + L2 정규화. + * 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로 + * 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다. + */ +@Injectable() +export class MockLlmProvider extends LlmProvider { + readonly name = 'mock'; + readonly model = 'mock-keyword-v1'; + + + async generate(ctx: MerchantContext): Promise { + const region = ctx.regionName ?? ''; + const industry = ctx.industryName ?? '업체'; + const p = ctx.profile; + const services = toStringArray(p['services']); + const features = toStringArray(p['features']); + const audiences = toStringArray(p['audiences']); + const nearby = toStringArray(p['nearby']); + const seasons = toStringArray(p['seasons']); + + const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약']; + const out: Array<[string, KeywordIntent, number]> = []; + const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]); + + // 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다. + push(ctx.name, 'brand', 0.99); + push(`${region} ${ctx.name}`, 'brand', 0.97); + push(`${region} ${industry}`, 'local', 0.95); + push(`${region} ${industry} 추천`, 'local', 0.93); + + for (const m of MODIFIERS) { + push(`${region} ${industry} ${m}`, intentOf(m), 0.86); + } + for (const a of audiences) { + push(`${region} ${a} ${industry}`, 'local', 0.88); + for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74); + push(`${a} ${industry} 추천`, 'informational', 0.62); + } + for (const f of features) { + push(`${region} ${f} ${industry}`, 'local', 0.84); + push(`${industry} ${f}`, 'informational', 0.6); + push(`${region} ${industry} ${f}`, 'local', 0.7); + } + for (const s of services) { + push(`${region} ${s}`, 'local', 0.82); + for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66); + } + for (const n of nearby) { + push(`${n} 근처 ${industry}`, 'local', 0.8); + push(`${n} ${industry} 추천`, 'local', 0.76); + push(`${n} 숙소`, 'local', 0.68); + } + for (const s of seasons) { + push(`${s} ${region} ${industry}`, 'local', 0.72); + push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64); + } + // 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다 + for (const a of audiences) { + for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42); + } + for (const a of audiences) { + for (const s of services) push(`${a} ${s}`, 'informational', 0.38); + } + // 질문형 (AEO 유입) + for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5); + for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44); + + const seen = new Set(); + const keywords: KeywordCandidate[] = []; + for (const [raw, intent, relevance] of out) { + const k = raw.replace(/\s+/g, ' ').trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` }); + if (keywords.length >= ctx.targetCount) break; + } + + const qaPairs: QaCandidate[] = [ + { + question: `${ctx.name}은(는) 어디에 있나요?`, + answer: `${ctx.name}은(는) ${region || '해당 지역'}에 위치한 ${industry}입니다.`, + }, + { + question: `${ctx.name} 예약은 어떻게 하나요?`, + answer: `${ctx.name}은(는) 사이트 예약 페이지 또는 전화로 예약할 수 있습니다.`, + }, + { + question: `${ctx.name}의 주요 서비스는 무엇인가요?`, + answer: services.length + ? `주요 서비스는 ${services.join(', ')} 입니다.` + : `${industry} 관련 서비스를 제공합니다.`, + }, + { + question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`, + answer: nearby.length + ? `${nearby.join(', ')} 등이 가깝습니다.` + : `${region} 주요 명소가 인근에 있습니다.`, + }, + { + question: `${ctx.name}에 ${audiences[0] ?? '반려동물'}도 갈 수 있나요?`, + answer: features.length + ? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.` + : `예약 전 상세 조건을 확인해 주세요.`, + }, + ]; + + return { keywords, qaPairs, model: this.model, provider: this.name }; + } +} + +function intentOf(modifier: string): KeywordIntent { + if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional'; + if (modifier === '후기' || modifier === '순위') return 'informational'; + return 'local'; +} + +function toStringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +/** 문자 bigram 해싱 임베딩 (결정적, L2 정규화) */ +export function hashEmbedding(text: string, dim = EMBEDDING_DIM): number[] { + const s = ` ${normalizeKeyword(text)} `; + const vec = new Float64Array(dim); + for (let i = 0; i < s.length - 1; i++) { + const gram = s.slice(i, i + 2); + const h = fnv1a(gram); + vec[h % dim] += 1; + // 부호 해싱으로 충돌 편향 완화 + vec[(h >>> 8) % dim] += h & 1 ? 1 : -1; + } + let norm = 0; + for (let i = 0; i < dim; i++) norm += vec[i] * vec[i]; + norm = Math.sqrt(norm) || 1; + return Array.from(vec, (x) => x / norm); +} + +function fnv1a(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} diff --git a/ontology/src/llm/openai.provider.ts b/ontology/src/llm/openai.provider.ts new file mode 100644 index 0000000..1b90db7 --- /dev/null +++ b/ontology/src/llm/openai.provider.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common'; +import OpenAI from 'openai'; +import { env } from '../config/env'; +import { GenerationOutput, LlmProvider, MerchantContext } from './types'; + +/** Structured Outputs 로 강제하는 응답 스키마 — 자유 텍스트 파싱 금지 */ +const RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['keywords', 'qa_pairs'], + properties: { + keywords: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['keyword', 'intent', 'relevance', 'rationale'], + properties: { + keyword: { type: 'string' }, + intent: { + type: 'string', + enum: ['informational', 'navigational', 'transactional', 'local', 'brand'], + }, + relevance: { type: 'number' }, + rationale: { type: 'string' }, + }, + }, + }, + qa_pairs: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['question', 'answer'], + properties: { + question: { type: 'string' }, + answer: { type: 'string' }, + }, + }, + }, + }, +} as const; + +@Injectable() +export class OpenAiLlmProvider extends LlmProvider { + readonly name = 'openai'; + readonly model = env.llm.model; + private readonly logger = new Logger(OpenAiLlmProvider.name); + private readonly client = new OpenAI({ apiKey: env.llm.apiKey }); + + + async generate(ctx: MerchantContext): Promise { + const res = await this.client.chat.completions.create({ + model: this.model, + temperature: 0.7, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildUserPrompt(ctx) }, + ], + response_format: { + type: 'json_schema', + json_schema: { name: 'seo_aeo_keywords', strict: true, schema: RESPONSE_SCHEMA as any }, + }, + }); + + const content = res.choices[0]?.message?.content ?? '{}'; + const parsed = JSON.parse(content) as { + keywords?: GenerationOutput['keywords']; + qa_pairs?: GenerationOutput['qaPairs']; + }; + + return { + keywords: parsed.keywords ?? [], + qaPairs: parsed.qa_pairs ?? [], + model: this.model, + provider: this.name, + usage: { + prompt_tokens: res.usage?.prompt_tokens ?? 0, + completion_tokens: res.usage?.completion_tokens ?? 0, + total_tokens: res.usage?.total_tokens ?? 0, + }, + }; + } +} + +const SYSTEM_PROMPT = `당신은 한국 로컬 비즈니스 SEO/AEO 전문가입니다. +주어진 업체 정보를 바탕으로 검색 유입에 실제로 도움이 되는 키워드와, +답변엔진(AI 검색)이 인용하기 좋은 질문-답변 쌍을 생성합니다. + +규칙: +- 실제 사용자가 검색창에 입력할 법한 자연스러운 한국어 표현만 사용합니다. +- 지역명 + 업종 + 의도어 조합을 적극 활용합니다. +- 과장광고 표현(최고, 1위, 100%, 무조건, 완치 등)은 절대 사용하지 않습니다. +- 제공된 "이미 보유한 키워드"와 의미가 겹치는 것은 생성하지 않습니다. +- relevance 는 해당 업체와의 관련도를 0.0~1.0 으로 매깁니다. +- 답변(answer)은 2~3문장, 업체 정보에 근거한 사실만 씁니다.`; + +function buildUserPrompt(ctx: MerchantContext): string { + return [ + `# 업체 정보`, + `- 상호: ${ctx.name}`, + `- 업종: ${ctx.industryName ?? '미상'} (${ctx.industryPath ?? '-'})`, + `- 지역: ${ctx.regionName ?? '미상'} (${ctx.regionPath ?? '-'})`, + `- 소개: ${ctx.description || '없음'}`, + `- 상세: ${JSON.stringify(ctx.profile, null, 2)}`, + ``, + `# 이미 보유한 키워드 (이것들과 겹치지 않는 새 후보만 생성)`, + ctx.existingKeywords.length ? ctx.existingKeywords.map((k) => `- ${k}`).join('\n') : '- (없음)', + ``, + `# 요청`, + `- 키워드 ${ctx.targetCount}개`, + `- 질문-답변 쌍 5개`, + ].join('\n'); +} diff --git a/ontology/src/llm/types.ts b/ontology/src/llm/types.ts new file mode 100644 index 0000000..e1c9db6 --- /dev/null +++ b/ontology/src/llm/types.ts @@ -0,0 +1,46 @@ +export type KeywordIntent = + | 'informational' + | 'navigational' + | 'transactional' + | 'local' + | 'brand'; + +export interface MerchantContext { + externalId: string; + name: string; + description: string; + industryName?: string | null; + industryPath?: string | null; + regionName?: string | null; + regionPath?: string | null; + profile: Record; + /** 이미 보유한 키워드 — 프롬프트에 넣어 중복 후보 생성 자체를 줄인다 */ + existingKeywords: string[]; + targetCount: number; +} + +export interface KeywordCandidate { + keyword: string; + intent: KeywordIntent; + relevance: number; // 0..1 + rationale: string; +} + +export interface QaCandidate { + question: string; + answer: string; +} + +export interface GenerationOutput { + keywords: KeywordCandidate[]; + qaPairs: QaCandidate[]; + model: string; + provider: string; + usage?: Record; +} + +export abstract class LlmProvider { + abstract readonly name: string; + abstract readonly model: string; + abstract generate(ctx: MerchantContext): Promise; +} diff --git a/ontology/src/main.ts b/ontology/src/main.ts new file mode 100644 index 0000000..909189e --- /dev/null +++ b/ontology/src/main.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { env } from './config/env'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.enableCors(); + await app.listen(env.port); + new Logger('bootstrap').log( + `o2o-site-ontology listening on http://localhost:${env.port} (llm=${env.llm.provider})`, + ); +} + +bootstrap(); diff --git a/ontology/src/merchants/merchants.controller.module.ts b/ontology/src/merchants/merchants.controller.module.ts new file mode 100644 index 0000000..85f5fbe --- /dev/null +++ b/ontology/src/merchants/merchants.controller.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GenerationModule } from '../generation/generation.module'; +import { MerchantsController } from './merchants.controller'; +import { MerchantsModule } from './merchants.module'; + +@Module({ + imports: [MerchantsModule, GenerationModule], + controllers: [MerchantsController], +}) +export class MerchantsHttpModule {} diff --git a/ontology/src/merchants/merchants.controller.ts b/ontology/src/merchants/merchants.controller.ts new file mode 100644 index 0000000..b0eed6f --- /dev/null +++ b/ontology/src/merchants/merchants.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { GenerationService } from '../generation/generation.service'; +import { GenerationQueue } from '../generation/generation.queue'; +import { MerchantsService, UpsertMerchantDto } from './merchants.service'; + +@Controller('v1/merchants') +export class MerchantsController { + constructor( + private readonly merchants: MerchantsService, + private readonly generation: GenerationService, + private readonly queue: GenerationQueue, + ) {} + + @Get() + list() { + return this.merchants.list(); + } + + @Get(':id') + get(@Param('id') id: string) { + return this.merchants.findWithTaxonomy(id); + } + + /** o2o-site-AEO 사이트 발행 웹훅: 업체 등록 + 키워드 생성 예약 */ + @Post('publish') + async publish(@Body() dto: UpsertMerchantDto & { generate?: boolean; sync?: boolean }) { + const merchant = await this.merchants.upsert(dto); + if (dto.generate === false) return { merchant, generation: 'skipped' }; + + if (dto.sync) { + const stats = await this.generation.runForMerchant(merchant.id, 'published'); + return { merchant, generation: stats }; + } + const jobId = await this.queue.enqueue(merchant.id, 'published'); + return { merchant, generation: { queued: true, jobId } }; + } + + /** 수동 재생성 */ + @Post(':id/generate') + async generate( + @Param('id') id: string, + @Query('sync') sync?: string, + @Query('count') count?: string, + ) { + const target = count ? Math.min(Math.max(1, Number(count)), 500) : undefined; + if (sync === 'true' || sync === '1') { + return this.generation.runForMerchant(id, 'manual', target); + } + const m = await this.merchants.findWithTaxonomy(id); + const jobId = await this.queue.enqueue(m.id, 'manual'); + return { queued: true, jobId }; + } +} diff --git a/ontology/src/merchants/merchants.module.ts b/ontology/src/merchants/merchants.module.ts new file mode 100644 index 0000000..17bc9bc --- /dev/null +++ b/ontology/src/merchants/merchants.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MerchantsService } from './merchants.service'; + +@Module({ + providers: [MerchantsService], + exports: [MerchantsService], +}) +export class MerchantsModule {} diff --git a/ontology/src/merchants/merchants.service.ts b/ontology/src/merchants/merchants.service.ts new file mode 100644 index 0000000..1ca386f --- /dev/null +++ b/ontology/src/merchants/merchants.service.ts @@ -0,0 +1,92 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { PG } from '../db/db.module'; +import { asJson, Sql } from '../db/db'; + +export interface MerchantRow { + id: string; + external_id: string; + name: string; + industry_id: string | null; + region_id: string | null; + description: string; + profile: Record; + site_url: string | null; + last_generated_at: Date | null; +} + +export interface MerchantWithTaxonomy extends MerchantRow { + industry_name: string | null; + industry_path: string | null; + region_name: string | null; + region_path: string | null; +} + +export interface UpsertMerchantDto { + externalId: string; + name: string; + industryId?: string | null; + regionId?: string | null; + description?: string; + profile?: Record; + siteUrl?: string | null; +} + +@Injectable() +export class MerchantsService { + constructor(@Inject(PG) private readonly sql: Sql) {} + + /** o2o-site-AEO 가 사이트를 발행할 때 호출하는 진입점 */ + async upsert(dto: UpsertMerchantDto): Promise { + const rows = await this.sql` + INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url) + VALUES (${dto.externalId}, ${dto.name}, ${dto.industryId ?? null}, ${dto.regionId ?? null}, + ${dto.description ?? ''}, ${this.sql.json(asJson(dto.profile ?? {}))}, ${dto.siteUrl ?? null}) + ON CONFLICT (external_id) DO UPDATE SET + name = EXCLUDED.name, + industry_id = EXCLUDED.industry_id, + region_id = EXCLUDED.region_id, + description = EXCLUDED.description, + profile = EXCLUDED.profile, + site_url = EXCLUDED.site_url, + updated_at = now() + RETURNING *`; + return rows[0]; + } + + async findWithTaxonomy(idOrExternalId: string): Promise { + const rows = await this.sql` + SELECT m.*, + i.name AS industry_name, i.path::text AS industry_path, + r.name AS region_name, r.path::text AS region_path + FROM merchant m + LEFT JOIN industry i ON i.id = m.industry_id + LEFT JOIN region r ON r.id = m.region_id + WHERE m.external_id = ${idOrExternalId} + OR (${isUuid(idOrExternalId)} AND m.id::text = ${idOrExternalId}) + LIMIT 1`; + if (!rows[0]) throw new NotFoundException(`merchant not found: ${idOrExternalId}`); + return rows[0]; + } + + async list(limit = 50) { + return this.sql` + SELECT * FROM merchant ORDER BY created_at DESC LIMIT ${limit}`; + } + + /** 주기 리프레시 대상: 한 번도 생성 안 됐거나 N일 지난 업체 */ + async findStale(intervalDays: number, limit: number) { + return this.sql>` + SELECT id, external_id FROM merchant + WHERE last_generated_at IS NULL + OR last_generated_at < now() - (${intervalDays} || ' days')::interval + ORDER BY last_generated_at NULLS FIRST + LIMIT ${limit}`; + } + + async markGenerated(merchantId: string) { + await this.sql`UPDATE merchant SET last_generated_at = now() WHERE id = ${merchantId}`; + } +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const isUuid = (v: string) => UUID_RE.test(v); diff --git a/ontology/src/serving/demo.controller.ts b/ontology/src/serving/demo.controller.ts new file mode 100644 index 0000000..52ea5aa --- /dev/null +++ b/ontology/src/serving/demo.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */ +@Controller() +export class DemoController { + @Get('demo') + @Header('content-type', 'text/html; charset=utf-8') + demo(): string { + return readFileSync(join(process.cwd(), 'public', 'demo.html'), 'utf8'); + } +} diff --git a/ontology/src/serving/match.rules.ts b/ontology/src/serving/match.rules.ts new file mode 100644 index 0000000..2a43e47 --- /dev/null +++ b/ontology/src/serving/match.rules.ts @@ -0,0 +1,182 @@ +/** + * 매칭 규칙 테이블. + * + * 두 종류가 있다. + * · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다) + * · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다 + * (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다) + */ + +export interface MerchantFacts { + name: string; + region: string | null; + industry: string | null; + description: string; + address: string | null; + areaGroup: AreaGroup | null; + capacityMax: number | null; + services: string[]; + features: string[]; + audiences: string[]; + nearby: string[]; + amenities: Set; // 정규화된 보유 시설 + unverified: Set; // 미확인 — 배제하지 않고 보류 처리 + /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */ + signals: string[]; +} + +export type AreaGroup = '해안·도서' | '원도심' | '시내'; + +export interface Lane { + key: string; + label: string; + weight: number; + text: string; +} + +// ──────────────────────────────────────────── 권역 판정 +const AREA_TERMS: Record = { + '해안·도서': ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도', '새만금', + '비응항', '비응도', '오식도', '해수욕장', '몽돌', '오션뷰', '바다뷰', '해변', '섬'], + '원도심': ['원도심', '신흥동', '영화동', '월명', '말랭이마을', '동국사', '초원사진관', '이성당', + '경암동', '근대역사', '근대문화', '시간여행', '해망굴', '째보선창', '뜬다리', '일본식가옥'], + '시내': ['나운동', '수송동', '미룡동', '조촌동', '은파호수공원', '군산역', '시외버스'], +}; + +export function detectAreaGroup(text: string): AreaGroup | null { + const hits = (Object.entries(AREA_TERMS) as [AreaGroup, string[]][]) + .map(([g, terms]) => [g, terms.filter((t) => text.includes(t)).length] as const) + .filter(([, n]) => n > 0) + .sort((a, b) => b[1] - a[1]); + return hits[0]?.[0] ?? null; +} + +/** 키워드가 어느 권역에 속하는지 (해당 없으면 null = 권역 중립) */ +export function keywordAreaGroup(keyword: string): AreaGroup | null { + return detectAreaGroup(keyword); +} + +// ──────────────────────────────────────────── 수용 인원 모순 +const GROUP_TERMS = ['단체', '워크샵', '워크숍', 'mt', '엠티', '20인', '15인', '10인', + '세미나', '단합', '대형', '펜션동', '전체대관']; +const GROUP_MIN_CAPACITY = 8; + +export function violatesCapacity(keyword: string, capacityMax: number | null): boolean { + if (capacityMax === null || capacityMax >= GROUP_MIN_CAPACITY) return false; + const k = keyword.toLowerCase(); + return GROUP_TERMS.some((t) => k.includes(t)); +} + +// ──────────────────────────────────────────── 시설 요구 조건 +/** 키워드에 이 말이 있으면 해당 시설을 실제로 보유해야 한다 */ +const AMENITY_REQUIRED: Array<[RegExp, string]> = [ + [/바베큐|바비큐|바베큐장|그릴/, '바베큐'], + [/수영장|풀빌라|인피니티풀|온수풀|야외수영/, '수영장'], + [/스파|자쿠지|월풀|반신욕/, '스파'], + [/애견|반려|펫/, '애견동반'], + [/주차/, '주차'], + [/노래방/, '노래방'], + [/조식/, '조식'], + [/키즈룸|트램폴린/, '키즈시설'], + [/불멍|화로대|캠프파이어/, '불멍'], + [/빔프로젝터|넷플릭스/, '미디어'], + [/세미나실/, '세미나실'], +]; + +/** 시설 표기 흔들림을 통제 어휘로 모은다 */ +const AMENITY_SYNONYMS: Array<[RegExp, string]> = [ + [/바베큐|바비큐|그릴/, '바베큐'], + [/수영장|풀|풀빌라/, '수영장'], + [/스파|자쿠지|월풀|욕조/, '스파'], + [/애견|반려|펫/, '애견동반'], + [/주차/, '주차'], + [/노래방/, '노래방'], + [/조식|아침/, '조식'], + [/키즈|트램폴린|유아/, '키즈시설'], + [/불멍|화로|캠프파이어/, '불멍'], + [/넷플릭스|빔프로젝터|ott/i, '미디어'], + [/세미나/, '세미나실'], +]; + +export function normalizeAmenities(raw: string[]): Set { + const out = new Set(); + for (const r of raw) { + for (const [re, canon] of AMENITY_SYNONYMS) if (re.test(r)) out.add(canon); + } + return out; +} + +export type AmenityVerdict = { ok: true } | { ok: false; hold: boolean; amenity: string }; + +export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerdict { + for (const [re, amenity] of AMENITY_REQUIRED) { + if (!re.test(keyword)) continue; + if (facts.amenities.has(amenity)) return { ok: true }; + // 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름" — 배제하지 않고 보류 + const unverified = [...facts.unverified].some((u) => + AMENITY_SYNONYMS.some(([sre, canon]) => canon === amenity && sre.test(u))); + return { ok: false, hold: unverified, amenity }; + } + return { ok: true }; +} + +// ──────────────────────────────────────────── 서브 질의 빌더 +const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성']; +const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/; + +/** + * 레인 설계 원칙 + * 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고, + * 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다. + * 지역+업종 앵커는 유형 레인에만 둔다. + * 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아 + * 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다). + * 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다. + */ +export function buildLanes(f: MerchantFacts): Lane[] { + const lanes: Lane[] = []; + // 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과 + // '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고, + // 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다). + const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => { + const seen = new Set(); + const words: string[] = []; + for (const part of parts) { + if (!part) continue; + for (const w of String(part).split(/\s+/)) { + const k = w.trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + words.push(k); + } + } + const text = words.join(' '); + if (text) lanes.push({ key, label, weight, text }); + }; + + const isType = (x: string) => STAY_TYPE_HINTS.some((h) => x.includes(h)); + const typeWords = [...f.features, ...f.services].filter(isType).slice(0, 3); + + // 시설: 유형어·수용인원·권역어를 걷어낸 나머지 + 정규화된 보유 시설 + const amenityWords = [ + ...f.amenities, + ...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)), + ].slice(0, 6); + + // 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐 + // 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다. + push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]); + push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']); + push('audience', '동반자', 0.6, f.audiences.slice(0, 4)); + push('amenity', '시설', 0.6, amenityWords); + // 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다 + push('signal', '고객언어', 0.9, f.signals.slice(0, 10)); + + return lanes; +} + +function districtOf(address: string | null): string | null { + if (!address) return null; + const m = address.match(/([가-힣]+(?:동|읍|면|리))/); + return m?.[1] ?? null; +} diff --git a/ontology/src/serving/match.service.ts b/ontology/src/serving/match.service.ts new file mode 100644 index 0000000..2a46081 --- /dev/null +++ b/ontology/src/serving/match.service.ts @@ -0,0 +1,253 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Sql, toVector } from '../db/db'; +import { PG } from '../db/db.module'; +import { EmbeddingProvider } from '../embedding/types'; +import { normalizeKeyword } from '../keywords/normalize'; +import { MerchantWithTaxonomy } from '../merchants/merchants.service'; +import { + AreaGroup, Lane, MerchantFacts, buildLanes, checkAmenity, detectAreaGroup, + keywordAreaGroup, normalizeAmenities, violatesCapacity, +} from './match.rules'; + +// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 +// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다. +const RRF_K = 20; +const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다 +const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 +// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. +const MATCH_SOURCES = ['dataset', 'nationwide', 'manual']; + +interface Hit { + id: string; canonical: string; intent: string; kind: string; + category: string | null; aliases: string[]; score: number; +} + +export interface MatchRow extends Hit { + rrf: number; + status: 'ok' | 'hold'; + holdReason?: string; + lanes: Array<{ key: string; label: string; rank: number; score: number }>; + linked: boolean; +} + +@Injectable() +export class MatchService { + constructor( + @Inject(PG) private readonly sql: Sql, + private readonly embedder: EmbeddingProvider, + ) {} + + /** 속성별 서브 질의 → 가중 RRF 융합 → 사실 기반 필터 */ + async fusion(rawQuery: string, limit: number) { + const query = rawQuery.trim(); + const merchant = await this.resolveMerchant(query); + const facts = merchant ? toFacts(merchant) : null; + + const lanes: Lane[] = facts + ? buildLanes(facts) + : [{ key: 'free', label: '입력문', weight: 1.0, text: query }]; + + const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query'); + + // 레인별 검색. + // 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은 + // 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다). + const perLane = await Promise.all( + vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)), + ); + + // 가중 RRF 융합 + const acc = new Map(); + perLane.forEach((hits, li) => { + const lane = lanes[li]; + hits.forEach((hit, idx) => { + if (hit.score < LANE_FLOOR) return; + const rank = idx + 1; + const contrib = lane.weight / (RRF_K + rank); + const cur = acc.get(hit.id) ?? { hit, rrf: 0, lanes: [] }; + cur.rrf += contrib; + cur.lanes.push({ key: lane.key, label: lane.label, rank, score: hit.score }); + if (hit.score > cur.hit.score) cur.hit = hit; + acc.set(hit.id, cur); + }); + }); + + // 사실 기반 필터 + const kept: MatchRow[] = []; + const excluded: Array<{ canonical: string; reason: string }> = []; + for (const { hit, rrf, lanes: ls } of acc.values()) { + if (facts) { + if (violatesCapacity(hit.canonical, facts.capacityMax)) { + excluded.push({ canonical: hit.canonical, reason: `최대 ${facts.capacityMax}인 — 단체 키워드` }); + continue; + } + const kwArea = keywordAreaGroup(hit.canonical); + if (kwArea && facts.areaGroup && kwArea !== facts.areaGroup) { + excluded.push({ canonical: hit.canonical, reason: `권역 불일치 — ${kwArea} (업체는 ${facts.areaGroup})` }); + continue; + } + const am = checkAmenity(hit.canonical, facts); + if (!am.ok && !am.hold) { + excluded.push({ canonical: hit.canonical, reason: `미보유 시설 — ${am.amenity}` }); + continue; + } + kept.push({ + ...hit, rrf, + lanes: ls.sort((a, b) => a.rank - b.rank), + status: am.ok ? 'ok' : 'hold', + holdReason: am.ok ? undefined : `${am.amenity} 미확인 — 사업자 확인 필요`, + linked: false, + }); + } else { + kept.push({ ...hit, rrf, lanes: ls.sort((a, b) => a.rank - b.rank), status: 'ok', linked: false }); + } + } + + kept.sort((a, b) => b.rrf - a.rrf); + const top = kept.slice(0, limit); + + // 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다. + // (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위) + const keptById = new Map(kept.map((k) => [k.id, k])); + const byLane = lanes.map((lane, li) => ({ + key: lane.key, label: lane.label, weight: lane.weight, text: lane.text, + items: perLane[li] + .map((h) => keptById.get(h.id)) + .filter((x): x is MatchRow => Boolean(x)) + .slice(0, 8), + })); + + await this.markLinked([...top, ...byLane.flatMap((l) => l.items)], merchant?.id ?? null); + + return { + mode: 'fusion' as const, + input: query, + resolved: merchant ? publicMerchant(merchant) : null, + facts: facts && { + areaGroup: facts.areaGroup, + capacityMax: facts.capacityMax, + amenities: [...facts.amenities], + unverified: [...facts.unverified], + }, + lanes: lanes.map((l, i) => ({ + ...l, top: perLane[i][0]?.canonical ?? null, topScore: perLane[i][0]?.score ?? null, + })), + embeddingProvider: this.embedder.name, + total: await this.dictionarySize(), + matches: top, + byLane, + excluded: excluded.slice(0, 40), + excludedTotal: excluded.length, + }; + } + + private async laneSearch( + embedding: number[], limit: number, industryId: string | null, + ): Promise { + const vec = toVector(embedding); + const rows = await this.sql` + SELECT id, canonical, intent, kind, category, aliases, + 1 - (embedding <=> ${vec}::vector) AS score + FROM keyword + WHERE embedding IS NOT NULL + AND source = ANY(${MATCH_SOURCES}) + AND (${industryId}::text IS NULL OR industry_id IS NULL OR industry_id = ${industryId}) + ORDER BY embedding <=> ${vec}::vector + LIMIT ${limit}`; + return rows.map((r) => ({ ...r, score: Number(r.score) })); + } + + private async markLinked(rows: MatchRow[], merchantId: string | null) { + if (!merchantId || rows.length === 0) return; + const ids = rows.map((r) => r.id); + const linked = await this.sql>` + SELECT keyword_id FROM merchant_keyword + WHERE merchant_id = ${merchantId} AND keyword_id = ANY(${ids}::uuid[])`; + const set = new Set(linked.map((l) => l.keyword_id)); + for (const r of rows) r.linked = set.has(r.id); + } + + async resolveMerchant(query: string) { + const norm = normalizeKeyword(query); + if (!norm) return null; + const rows = await this.sql>` + SELECT m.*, + i.name AS industry_name, i.path::text AS industry_path, + r.name AS region_name, r.path::text AS region_path, + similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim + FROM merchant m + LEFT JOIN industry i ON i.id = m.industry_id + LEFT JOIN region r ON r.id = m.region_id + WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm} + OR m.external_id = ${query} + OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45 + ORDER BY sim DESC NULLS LAST + LIMIT 1`; + return rows[0] ?? null; + } + + async dictionarySize() { + const [row] = await this.sql>` + SELECT count(*)::int AS n FROM keyword + WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`; + return row?.n ?? 0; + } +} + +function str(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +function toFacts(m: MerchantWithTaxonomy): MerchantFacts { + const p = (m.profile ?? {}) as Record; + const features = str(p['features']); + const services = str(p['services']); + const nearby = str(p['nearby']); + const address = typeof p['address'] === 'string' ? p['address'] : null; + const cap = p['capacity'] as { max?: number } | undefined; + + const areaSource = [address ?? '', ...nearby, m.description].join(' '); + const areaGroup: AreaGroup | null = detectAreaGroup(areaSource); + + return { + name: m.name, + region: m.region_name, + industry: m.industry_name, + description: m.description, + address, + areaGroup, + capacityMax: typeof cap?.max === 'number' ? cap.max : null, + services, features, + audiences: str(p['audiences']), + nearby, + amenities: normalizeAmenities([...features, ...services]), + unverified: new Set(str(p['unverified'])), + signals: collectSignals(p), + }; +} + +/** + * 고객 언어 신호를 모은다. + * hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등 + * reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계 + * 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다. + */ +function collectSignals(p: Record): string[] { + const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean); + const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : []; + const reviews = raw + .filter((r): r is { term: string; count: number } => + Boolean(r) && typeof (r as any).term === 'string') + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0)) + .map((r) => r.term.trim()) + .filter(Boolean); + return [...new Set([...reviews, ...tags])]; +} + +function publicMerchant(m: MerchantWithTaxonomy) { + return { + id: m.id, externalId: m.external_id, name: m.name, + region: m.region_name, industry: m.industry_name, + description: m.description, siteUrl: m.site_url, profile: m.profile ?? {}, + }; +} diff --git a/ontology/src/serving/serving.controller.ts b/ontology/src/serving/serving.controller.ts new file mode 100644 index 0000000..ce2d54f --- /dev/null +++ b/ontology/src/serving/serving.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { MatchService } from './match.service'; +import { ServingService } from './serving.service'; + +@Controller('v1') +export class ServingController { + constructor( + private readonly serving: ServingService, + private readonly matcher: MatchService, + ) {} + + /** 발행된 사이트가 렌더링 시 호출 — SEO 메타 */ + @Get('sites/:id/seo') + seo(@Param('id') id: string, @Query('limit') limit?: string) { + return this.serving.seo(id, clamp(limit, 20, 50)); + } + + /** 발행된 사이트가 렌더링 시 호출 — AEO(답변엔진) 소스 */ + @Get('sites/:id/aeo') + aeo(@Param('id') id: string, @Query('limit') limit?: string) { + return this.serving.aeo(id, clamp(limit, 10, 30)); + } + + /** 어드민: 의미 기반 키워드 검색 */ + @Post('keywords/search') + search(@Body() body: { query: string; limit?: number }) { + return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50)); + } + + /** + * 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. + * mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터 + * mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용) + */ + @Post('match') + match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) { + const limit = Math.min(body.limit ?? 40, 200); + return body.mode === 'single' + ? this.serving.match(body.query ?? '', limit) + : this.matcher.fusion(body.query ?? '', limit); + } + + /** 성과 피드백 주입 (Search Console / 유입 로그) */ + @Post('sites/:id/performance') + performance( + @Param('id') id: string, + @Body() body: { items: Array<{ keyword: string; impressions: number; clicks: number }> }, + ) { + return this.serving.applyPerformance(id, body.items ?? []); + } +} + +function clamp(v: string | undefined, def: number, max: number): number { + const n = v ? Number(v) : def; + return Number.isFinite(n) ? Math.min(Math.max(1, n), max) : def; +} diff --git a/ontology/src/serving/serving.module.ts b/ontology/src/serving/serving.module.ts new file mode 100644 index 0000000..1ebdedf --- /dev/null +++ b/ontology/src/serving/serving.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { KeywordsModule } from '../keywords/keywords.module'; +import { MerchantsModule } from '../merchants/merchants.module'; +import { DemoController } from './demo.controller'; +import { MatchService } from './match.service'; +import { ServingController } from './serving.controller'; +import { ServingService } from './serving.service'; + +@Module({ + imports: [MerchantsModule, KeywordsModule], + controllers: [ServingController, DemoController], + providers: [ServingService, MatchService], +}) +export class ServingModule {} diff --git a/ontology/src/serving/serving.service.ts b/ontology/src/serving/serving.service.ts new file mode 100644 index 0000000..6a7fb1e --- /dev/null +++ b/ontology/src/serving/serving.service.ts @@ -0,0 +1,230 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PG } from '../db/db.module'; +import { Sql, toVector } from '../db/db'; +import { KeywordRepository } from '../keywords/keyword.repository'; +import { normalizeKeyword } from '../keywords/normalize'; +import { EmbeddingProvider } from '../embedding/types'; +import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service'; + +export interface SeoPayload { + merchant: { id: string; externalId: string; name: string; siteUrl: string | null }; + title: string; + description: string; + keywords: string[]; + tags: Array<{ keyword: string; intent: string; relevance: number; aliases: string[] }>; + generatedAt: string | null; +} + +export interface AeoPayload { + merchant: { id: string; externalId: string; name: string }; + topics: string[]; + faqs: Array<{ question: string; answer: string }>; + /** JSON-LD 는 후속 단계에서 이 payload 를 그대로 매핑해 생성한다 */ + structuredDataHints: { + type: 'LocalBusiness'; + name: string; + description: string; + areaServed: string | null; + category: string | null; + }; +} + +@Injectable() +export class ServingService { + constructor( + @Inject(PG) private readonly sql: Sql, + private readonly merchants: MerchantsService, + private readonly keywords: KeywordRepository, + private readonly embedder: EmbeddingProvider, + ) {} + + async seo(idOrExternalId: string, limit: number): Promise { + const m = await this.merchants.findWithTaxonomy(idOrExternalId); + const rows = await this.sql< + Array<{ canonical: string; intent: string; relevance: number; aliases: string[] }> + >` + SELECT k.canonical, k.intent, mk.relevance, k.aliases + FROM merchant_keyword mk + JOIN keyword k ON k.id = mk.keyword_id + WHERE mk.merchant_id = ${m.id} AND mk.status = 'active' + ORDER BY mk.relevance DESC, k.usage_count ASC + LIMIT ${limit}`; + + const kws = rows.map((r) => r.canonical); + const locality = [m.region_name, m.industry_name].filter(Boolean).join(' '); + + return { + merchant: { id: m.id, externalId: m.external_id, name: m.name, siteUrl: m.site_url }, + title: locality ? `${m.name} | ${locality}` : m.name, + description: buildDescription(m.name, m.description, kws), + keywords: kws, + tags: rows.map((r) => ({ + keyword: r.canonical, + intent: r.intent, + relevance: Number(r.relevance), + aliases: r.aliases ?? [], + })), + generatedAt: m.last_generated_at ? new Date(m.last_generated_at).toISOString() : null, + }; + } + + async aeo(idOrExternalId: string, limit: number): Promise { + const m = await this.merchants.findWithTaxonomy(idOrExternalId); + const faqs = await this.sql>` + SELECT question, answer FROM qa_pair + WHERE merchant_id = ${m.id} AND status = 'active' + ORDER BY created_at ASC + LIMIT ${limit}`; + const topics = await this.sql>` + SELECT k.canonical FROM merchant_keyword mk + JOIN keyword k ON k.id = mk.keyword_id + WHERE mk.merchant_id = ${m.id} AND mk.status = 'active' + AND k.intent IN ('informational', 'local') + ORDER BY mk.relevance DESC LIMIT ${limit}`; + + return { + merchant: { id: m.id, externalId: m.external_id, name: m.name }, + topics: topics.map((t) => t.canonical), + faqs, + structuredDataHints: { + type: 'LocalBusiness', + name: m.name, + description: m.description, + areaServed: m.region_name, + category: m.industry_name, + }, + }; + } + + /** + * 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. + * 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 — + * 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다. + */ + async match(rawQuery: string, limit: number) { + const query = rawQuery.trim(); + const merchant = await this.resolveMerchant(query); + + const queryText = merchant + ? [ + merchant.name, + merchant.region_name, + merchant.industry_name, + merchant.description, + ...toStrings(merchant.profile?.['services']), + ...toStrings(merchant.profile?.['features']), + ...toStrings(merchant.profile?.['audiences']), + ...toStrings(merchant.profile?.['nearby']), + ].filter(Boolean).join(' ') + : query; + + const [embedding] = await this.embedder.embed([queryText], 'query'); + const rows = await this.sql>` + SELECT k.id, k.canonical, k.intent, k.kind, k.category, k.aliases, k.usage_count, + 1 - (k.embedding <=> ${toVector(embedding)}::vector) AS score, + (mk.merchant_id IS NOT NULL) AS linked + FROM keyword k + LEFT JOIN merchant_keyword mk + ON mk.keyword_id = k.id AND mk.merchant_id = ${merchant?.id ?? null} + WHERE k.embedding IS NOT NULL + ORDER BY k.embedding <=> ${toVector(embedding)}::vector + LIMIT ${limit}`; + + return { + mode: 'single' as const, + input: query, + resolved: merchant + ? { + id: merchant.id, externalId: merchant.external_id, name: merchant.name, + region: merchant.region_name, industry: merchant.industry_name, + description: merchant.description, siteUrl: merchant.site_url, + profile: merchant.profile ?? {}, + } + : null, + queryText, + embeddingProvider: this.embedder.name, + total: await this.dictionarySize(), + matches: rows.map((r) => ({ ...r, score: Number(r.score) })), + }; + } + + /** 상호는 띄어쓰기가 흔들린다 — 정규화 일치 → trigram 순으로 해석 */ + private async resolveMerchant(query: string) { + const norm = normalizeKeyword(query); + if (!norm) return null; + const rows = await this.sql>` + SELECT m.*, + i.name AS industry_name, i.path::text AS industry_path, + r.name AS region_name, r.path::text AS region_path, + similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim + FROM merchant m + LEFT JOIN industry i ON i.id = m.industry_id + LEFT JOIN region r ON r.id = m.region_id + WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm} + OR m.external_id = ${query} + OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45 + ORDER BY sim DESC NULLS LAST + LIMIT 1`; + return rows[0] ?? null; + } + + async dictionarySize() { + const [row] = await this.sql>` + SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`; + return row?.n ?? 0; + } + + async searchKeywords(query: string, limit: number) { + const [embedding] = await this.embedder.embed([query], 'query'); + return this.keywords.searchByVector(embedding, 'ko-KR', limit); + } + + /** Search Console / 유입 로그 피드백 → 저성과 키워드 강등 */ + async applyPerformance( + idOrExternalId: string, + items: Array<{ keyword: string; impressions: number; clicks: number }>, + ) { + const m = await this.merchants.findWithTaxonomy(idOrExternalId); + let updated = 0; + for (const it of items) { + const rows = await this.sql>` + UPDATE merchant_keyword mk + SET impressions = mk.impressions + ${it.impressions}, + clicks = mk.clicks + ${it.clicks}, + ctr = CASE WHEN (mk.impressions + ${it.impressions}) > 0 + THEN (mk.clicks + ${it.clicks})::real / (mk.impressions + ${it.impressions}) + ELSE 0 END, + updated_at = now() + FROM keyword k + WHERE k.id = mk.keyword_id + AND mk.merchant_id = ${m.id} + AND (k.canonical = ${it.keyword} OR ${it.keyword} = ANY(k.aliases)) + RETURNING mk.keyword_id`; + updated += rows.length; + } + + // 노출은 충분한데 클릭이 없는 키워드는 강등 → 다음 생성 사이클에서 대체 + const demoted = await this.sql>` + UPDATE merchant_keyword + SET status = 'demoted', updated_at = now() + WHERE merchant_id = ${m.id} AND status = 'active' + AND impressions >= 100 AND ctr < 0.002 + RETURNING keyword_id`; + + return { matched: updated, demoted: demoted.length }; + } +} + +function toStrings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +function buildDescription(name: string, desc: string, keywords: string[]): string { + const base = desc?.trim() || `${name} 안내`; + const tail = keywords.slice(0, 3).join(', '); + const full = tail ? `${base} ${tail} 정보를 확인하세요.` : base; + return full.length > 155 ? `${full.slice(0, 152)}...` : full; +} diff --git a/ontology/tsconfig.build.json b/ontology/tsconfig.build.json new file mode 100644 index 0000000..3151a23 --- /dev/null +++ b/ontology/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "scripts"] +} diff --git a/ontology/tsconfig.json b/ontology/tsconfig.json new file mode 100644 index 0000000..09dc08e --- /dev/null +++ b/ontology/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/package-lock.json b/package-lock.json index d6075fc..4e350f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "solution/site", "admin/frontend" ], + "devDependencies": { + "playwright": "^1.63.0" + }, "engines": { "node": ">=20" } @@ -7337,6 +7340,35 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", diff --git a/package.json b/package.json index e626ded..3c0818e 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ }, "engines": { "node": ">=20" + }, + "devDependencies": { + "playwright": "^1.63.0" } } diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 3cf81fd..229e400 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -93,11 +93,12 @@ CREATE TABLE IF NOT EXISTS public.users ( -- ============================================================ CREATE TABLE IF NOT EXISTS public.jobs ( job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다 - job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check + job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check 7=song 8=rollback status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선 payload JSONB NOT NULL DEFAULT '{}'::jsonb, result JSONB NULL, + progress JSONB NULL, dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code} attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1 max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD @@ -229,20 +230,24 @@ CREATE TABLE IF NOT EXISTS public.place_facts ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); --- ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다. +-- FAQ. 출처(generated_by)마다 근거 요구가 다르다 — LLM 생성분은 source_fact_ids 필수(없으면 저장 안 함), +-- 사장님 입력(OWNER)·20개 채우기 문의 안내(TEMPLATE)는 근거가 없다. 규칙: docs/DECISIONS.md 8절 CREATE TABLE IF NOT EXISTS public.place_faqs ( faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), place_id uuid NOT NULL, question VARCHAR(500) NOT NULL, answer TEXT NOT NULL, - source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려 - generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner + source_fact_ids JSONB NULL, -- 근거 fact key 배열(LLM 생성분만) + generated_by SMALLINT NOT NULL, -- SourceType: 1=owner 4=llm 5=template(문의 안내) status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출 sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- migrations/0012 와 같은 문구다 — 바꾸면 둘 다 고친다. +COMMENT ON COLUMN public.place_faqs.generated_by IS 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)'; +COMMENT ON COLUMN public.place_faqs.source_fact_ids IS '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.'; -- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다. -- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다. @@ -313,6 +318,25 @@ CREATE TABLE IF NOT EXISTS public.place_area_refs ( -- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다. -- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다. -- ============================================================ +CREATE TABLE IF NOT EXISTS public.site_search_status ( + site_id uuid PRIMARY KEY, + site_version_id uuid NOT NULL, + property_url TEXT NOT NULL, + page_url TEXT NOT NULL, + published_at TIMESTAMPTZ NOT NULL, + sitemap_submitted_at TIMESTAMPTZ NULL, + inspected_at TIMESTAMPTZ NULL, + first_indexed_at TIMESTAMPTZ NULL, + inspection JSONB NULL, + error_code VARCHAR(100) NULL, + failures INTEGER NOT NULL DEFAULT 0, + next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(), + alerted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT false +); + CREATE TABLE IF NOT EXISTS public.sites ( site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), place_id uuid NOT NULL, -- 사업장과 1:1 diff --git a/postgres-init/migrations/0012_place_faqs_template_source.sql b/postgres-init/migrations/0012_place_faqs_template_source.sql new file mode 100644 index 0000000..1dbdc7f --- /dev/null +++ b/postgres-init/migrations/0012_place_faqs_template_source.sql @@ -0,0 +1,29 @@ +-- 0012 · place_faqs 출처 코드값 5(TEMPLATE) — FAQ 20개 채우기의 문의 안내 문항 +-- +-- ★ 컬럼·제약 변경은 없다. generated_by 는 CHECK 없는 SMALLINT 라 값 5 가 그대로 들어간다 +-- (로컬 DB 제약 조회 2026-09-14: place_faqs_pkey 하나뿐). 이 파일은 코드값의 뜻을 DB 에 남긴다 — +-- psql 로 행을 보는 사람이 5 를 모르는 값으로 읽지 않게. +-- ★ 옛 주석 "source_fact_ids 가 비면 발행 게이트가 반려한다" 는 사실이 아니었다 — 그런 검사는 없고, +-- 근거 강제는 저장 시점(copy_service)에 있다. 규칙: docs/DECISIONS.md 8절 +-- ★ 컬럼이 있을 때만 단다. 0005(표 이름 이동) 전 구조의 DB 에서 손으로 돌려도 실패하지 않게 — +-- COMMENT ON 은 대상이 없으면 에러다. 대상이 있으면 덮어쓰기라 재실행해도 안전하다. +-- init.sql 의 place_faqs 뒤에 같은 COMMENT ON 이 있다 — 문구를 바꾸면 둘 다 고친다. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'generated_by' + ) THEN + COMMENT ON COLUMN public.place_faqs.generated_by IS + 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)'; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'source_fact_ids' + ) THEN + COMMENT ON COLUMN public.place_faqs.source_fact_ids IS + '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.'; + END IF; +END $$; diff --git a/postgres-init/migrations/0013_job_progress.sql b/postgres-init/migrations/0013_job_progress.sql new file mode 100644 index 0000000..f3c3d06 --- /dev/null +++ b/postgres-init/migrations/0013_job_progress.sql @@ -0,0 +1,2 @@ +-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다. +ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL; diff --git a/postgres-init/migrations/0014_search_console.sql b/postgres-init/migrations/0014_search_console.sql new file mode 100644 index 0000000..c6e4b69 --- /dev/null +++ b/postgres-init/migrations/0014_search_console.sql @@ -0,0 +1,19 @@ +-- 발행 상태를 바꾸지 않고 Google 제출/검사 결과를 추적한다. +CREATE TABLE IF NOT EXISTS public.site_search_status ( + site_id uuid PRIMARY KEY, + site_version_id uuid NOT NULL, + property_url TEXT NOT NULL, + page_url TEXT NOT NULL, + published_at TIMESTAMPTZ NOT NULL, + sitemap_submitted_at TIMESTAMPTZ NULL, + inspected_at TIMESTAMPTZ NULL, + first_indexed_at TIMESTAMPTZ NULL, + inspection JSONB NULL, + error_code VARCHAR(100) NULL, + failures INTEGER NOT NULL DEFAULT 0, + next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(), + alerted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT false +); diff --git a/postgres-init/migrations/0012_owner_social_accounts.sql b/postgres-init/migrations/0015_owner_social_accounts.sql similarity index 61% rename from postgres-init/migrations/0012_owner_social_accounts.sql rename to postgres-init/migrations/0015_owner_social_accounts.sql index 0c700bd..8d25fa0 100644 --- a/postgres-init/migrations/0012_owner_social_accounts.sql +++ b/postgres-init/migrations/0015_owner_social_accounts.sql @@ -1,4 +1,9 @@ --- SNS: credentials and approval records never enter public payloads. +-- 0015 · SNS 계정 연결 +-- +-- ★ 번호가 0012·0013 이었는데 main 에 같은 번호가 이미 있어서(0012_place_faqs_template_source, +-- 0013_job_progress) 병합하며 뒤로 밀었다. migrate.py 는 파일명 정렬로 돌고 적용 이력도 +-- 파일명(stem)으로 남기므로, 이미 옛 이름으로 적용된 DB 는 이 파일을 한 번 더 돌린다 — +-- 안에 있는 문장이 전부 IF NOT EXISTS 라 두 번 돌아도 결과가 같다(그래서 밀 수 있었다). CREATE TABLE IF NOT EXISTS public.owner_social_accounts ( account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL, diff --git a/postgres-init/migrations/0013_place_social_posts.sql b/postgres-init/migrations/0016_place_social_posts.sql similarity index 71% rename from postgres-init/migrations/0013_place_social_posts.sql rename to postgres-init/migrations/0016_place_social_posts.sql index b778fdf..79740cd 100644 --- a/postgres-init/migrations/0013_place_social_posts.sql +++ b/postgres-init/migrations/0016_place_social_posts.sql @@ -1,4 +1,9 @@ --- SNS: credentials and approval records never enter public payloads. +-- 0016 · SNS 게재 글 +-- +-- ★ 번호가 0012·0013 이었는데 main 에 같은 번호가 이미 있어서(0012_place_faqs_template_source, +-- 0013_job_progress) 병합하며 뒤로 밀었다. migrate.py 는 파일명 정렬로 돌고 적용 이력도 +-- 파일명(stem)으로 남기므로, 이미 옛 이름으로 적용된 DB 는 이 파일을 한 번 더 돌린다 — +-- 안에 있는 문장이 전부 IF NOT EXISTS 라 두 번 돌아도 결과가 같다(그래서 밀 수 있었다). CREATE TABLE IF NOT EXISTS public.place_social_posts ( post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), place_id uuid NOT NULL, diff --git a/solution/backend/Dockerfile b/solution/backend/Dockerfile index b6fef95..3e49c19 100644 --- a/solution/backend/Dockerfile +++ b/solution/backend/Dockerfile @@ -16,6 +16,11 @@ WORKDIR /app # 의존성 먼저 설치 (레이어 캐시 활용) COPY solution/backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다 — 패키지(pip)만으로는 +# 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는 시스템 라이브러리 +# (libnss3 등)까지 apt 로 같이 깐다. 세 프로세스(웹·admin·워커)가 이 이미지 하나를 +# 공유하므로 실제로 fetch() 를 호출하는 건 워커뿐이어도 여기서 한 번만 설치한다. +RUN playwright install --with-deps chromium COPY solution/backend ./solution/backend COPY admin/backend ./admin/backend diff --git a/solution/backend/Dockerfile.worker b/solution/backend/Dockerfile.worker new file mode 100644 index 0000000..4f1ecd8 --- /dev/null +++ b/solution/backend/Dockerfile.worker @@ -0,0 +1,70 @@ +# 워커 전용 이미지 — solution-backend(:9800)·admin-backend(:9801) 와 **다른 이미지**다. +# +# ★ 왜 갈랐나 (프리렌더를 워커 실행으로 통합, 2026-09-15) +# BUILD·ROLLBACK 잡은 이제 컴파일된 Node 렌더러(solution/site)를 subprocess 로 직접 +# 돌린다(services/render_service.py) — 예전에 별도 컨테이너(solution-prerender)가 하던 +# 일이다. 그 렌더러가 필요한 건 **워커뿐**이다. Dockerfile(api/admin 겸용)에 Node 를 +# 같이 넣으면 API 를 여는 요청만 받는 컨테이너에도 쓰지 않는 Node 런타임과 렌더러 +# 번들이 실려 나간다 — 이미지가 커지고 공격 표면도 는다. +# +# ★ 기동·잡 실행 중에 npm install 을 하지 않는다. site-builder 스테이지가 이미지 빌드 +# 시점에 한 번 굽고, 최종 이미지는 컴파일된 산출물만 COPY 한다. +# ★ node_modules 는 안 들어간다 — solution/site/vite.config.ts 가 SSR 번들에 +# `ssr.noExternal: true` 로 react 등 의존성까지 전부 접어 넣는다(실측: node_modules 를 +# 지우고 실행해도 그대로 돈다, dist/prerender/prerender.js 단일 파일 1.8MB). +# +# ★ 빌드 컨텍스트는 solution-backend 와 같은 **레포 루트**다 — solution/site 와 +# solution/backend 를 한 이미지에 담아야 한다. + +# ── 1단계: 렌더러 컴파일 (Node) ────────────────────────────────────────────── +FROM node:24-bookworm AS site-builder +WORKDIR /app + +COPY package.json package-lock.json tsconfig.base.json ./ +COPY solution/shared/package.json solution/shared/ +COPY solution/frontend/package.json solution/frontend/ +COPY solution/site/package.json solution/site/ +COPY admin/frontend/package.json admin/frontend/ +# ★ npm workspaces 는 루트 package.json 의 workspaces 목록에 있는 자리마다 package.json 을 +# 요구한다 — site 만 빌드해도 4개를 전부 COPY 해야 npm ci 가 성공한다(nginx/Dockerfile 과 같다). +RUN npm ci + +COPY solution/shared ./solution/shared +COPY solution/site ./solution/site +RUN npm run build:client -w @o2o/site \ + && npm run build:prerender -w @o2o/site + +# ── 2단계: Node 런타임만 (바이너리를 뽑아 쓴다) ────────────────────────────── +# ★ python:3.12-slim 과 같은 계열(Debian bookworm)의 태그를 써야 한다 — glibc ABI 가 +# 어긋나면 바이너리만 복사해서는 못 돈다("cannot execute binary file" 류로 죽는다). +FROM node:24-bookworm-slim AS node-runtime + +# ── 3단계: 최종 워커 이미지 ─────────────────────────────────────────────── +FROM python:3.12-slim-bookworm +WORKDIR /app + +COPY solution/backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다(수집 파이프라인, 워커 전용 경로) — +# 패키지(pip)만으로는 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는 +# 시스템 라이브러리(libnss3 등)까지 apt 로 같이 깐다. +RUN playwright install --with-deps chromium + +# Node 바이너리만 옮겨 온다 — npm·corepack 은 쓰지 않는다(빌드는 site-builder 스테이지가 +# 이미 끝냈다). /usr/local/bin/node 하나로 `node dist/prerender/prerender.js` 를 돌리는 +# 데 충분하다(실측: 아래 COPY 만으로 `node --version` 과 렌더 스모크 테스트가 통과한다). +COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node + +COPY solution/backend ./solution/backend +# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로 +# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야 +# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다. +COPY --from=site-builder /app/solution/site/dist ./solution/site/dist +COPY --from=site-builder /app/solution/site/public ./solution/site/public + +ENV APP_ENV=local +ENV PYTHONPATH=/app/solution/backend +WORKDIR /app/solution/backend + +# 워커는 포트가 없다 — 이미지 공통 HEALTHCHECK 를 두지 않는다(compose 가 disable 한다). +CMD ["python", "worker_main.py"] diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index bb1c380..82bb55a 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -280,7 +280,12 @@ class place_facts(MainTableMixin, MAIN_BASE): class place_faqs(MainTableMixin, MAIN_BASE): - """FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다.""" + """FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다. + + LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다) + OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다 + TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다. + ★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다.""" __tablename__ = "place_faqs" @@ -288,8 +293,8 @@ class place_faqs(MainTableMixin, MAIN_BASE): place_id = Column(UUID(as_uuid=True), nullable=False, index=True) question = Column(String(500), nullable=False) answer = Column(Text, nullable=False) - source_fact_ids = Column(JSONB, nullable=True) # ★ 근거 fact_id 배열. 비면 반려 - generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER + source_fact_ids = Column(JSONB, nullable=True) # 근거 fact key 배열 — LLM 생성분만 채운다 + generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER | TEMPLATE status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value) sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) @@ -449,6 +454,25 @@ class sites(MainTableMixin, MAIN_BASE): thumbnail_url = Column(String(500), nullable=True) +class site_search_status(MainTableMixin, MAIN_BASE): + """발행 성공과 Google 색인 성공은 다른 사건이라 별도 보관한다.""" + + __tablename__ = "site_search_status" + site_id = Column(UUID(as_uuid=True), primary_key=True) + site_version_id = Column(UUID(as_uuid=True), nullable=False) + property_url = Column(Text, nullable=False) + page_url = Column(Text, nullable=False) + published_at = Column(DateTime(timezone=True), nullable=False) + sitemap_submitted_at = Column(DateTime(timezone=True), nullable=True) + inspected_at = Column(DateTime(timezone=True), nullable=True) + first_indexed_at = Column(DateTime(timezone=True), nullable=True) + inspection = Column(JSONB, nullable=True) + error_code = Column(String(100), nullable=True) + failures = Column(Integer, nullable=False, server_default=text("0")) + next_check_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) + alerted_at = Column(DateTime(timezone=True), nullable=True) + + class site_sections(MainTableMixin, MAIN_BASE): """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. @@ -558,6 +582,7 @@ class jobs(MainTableMixin, MAIN_BASE): priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선 payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력 result = Column(JSONB, nullable=True) # 잡 출력(완료 시) + progress = Column(JSONB, nullable=True) # 워커가 기록한 단계 상태 dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크) attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1 max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index c32a2cd..ace6594 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -206,13 +206,14 @@ class PlaceStatus(CodeEnum): class SourceType(CodeEnum): - """facts.source_type / media.source_type / place_aliases.source_type 공용 코드값. + """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. 값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.""" - OWNER = 1 # 사장님이 직접 입력·업로드 - API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) - CRAWL = 3 # 크롤링 - LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 + OWNER = 1 # 사장님이 직접 입력·업로드 + API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) + CRAWL = 3 # 크롤링 + LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 + TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다 class FactStatus(CodeEnum): @@ -317,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", @@ -327,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): @@ -342,6 +343,7 @@ class LocalSource(CodeEnum): # ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 — # 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다. LLM = 5 + NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다 class LocalContentStatus(CodeEnum): @@ -387,6 +389,7 @@ class PublishAction(CodeEnum): REBUILD = 3 SUSPEND = 4 RESUME = 5 + ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py class PublishResult(CodeEnum): @@ -431,9 +434,14 @@ class JobType(CodeEnum): BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위 LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회) AI_CHECK = 6 # AI 검색 노출 점검 - SOCIAL_DRAFT = 8 - SOCIAL_POST = 9 SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다 + ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림 + # ★ 번호를 재사용하지 않는다. 같은 값을 두 번 쓰면 파이썬 enum 이 **조용히 별칭**으로 묶어 + # (`JobType.ROLLBACK is JobType.SOCIAL_DRAFT` → True) 한쪽 잡이 남의 핸들러로 간다. + # 실제로 그랬다(2026-09-16, 병합): SNS 가 8·9 를, 롤백이 8 을 각자 가져와 겹쳤다. + # 에러도 안 나고 `HANDLERS` 조회만 조용히 뒤바뀐다 — 다음 번호는 늘 마지막 +1 이다. + SOCIAL_DRAFT = 9 # SNS 소개글 초안 작성 (Gemini) — 사장님이 누를 때만 + SOCIAL_POST = 10 # 승인된 글을 사장님 계정으로 게시 (Threads) class JobStatus(CodeEnum): diff --git a/solution/backend/common/faq_catalog/__init__.py b/solution/backend/common/faq_catalog/__init__.py new file mode 100644 index 0000000..1b5efc6 --- /dev/null +++ b/solution/backend/common/faq_catalog/__init__.py @@ -0,0 +1,135 @@ +"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문. + +★ 왜 필요한가 + COPY 잡은 확인된 fact 로만 FAQ 를 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건, + 산하연 풀빌라 4건 — 그 근거로는 FAQ 가 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다. + +★ 공통 **답**은 주장을 하지 않는다 — "…은 숙소로 문의 부탁드립니다" 뿐이다. + 예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는 + 조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 값·가능 여부를 적으면 + 같은 사고다. 문의 안내만 쓴다. + +resources/*.json 을 최초 사용 시 로드·검증한다. fact_keys 는 업종 스키마에 있는 key 여야 한다 — +오타가 난 key 는 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다. +""" +import json +from dataclasses import dataclass +from pathlib import Path + +from common.category_schema import get_schema +from common.enums import PlaceCategory + +_RESOURCE_DIR = Path(__file__).parent / "resources" + +_catalogs: list | None = None + + +class FaqCatalogError(RuntimeError): + """카탈로그 리소스 로드/검증 실패.""" + + +@dataclass(frozen=True) +class CatalogItem: + id: str + question: str + topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부") + fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다 + keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교) + + +@dataclass(frozen=True) +class FaqCatalog: + name: str + label: str + category: PlaceCategory + exclude_external_categories: tuple[str, ...] + fallback_with_contact: tuple[str, ...] + fallback_without_contact: tuple[str, ...] + items: tuple[CatalogItem, ...] + + def applies_to(self, category: int, external_category: str | None) -> bool: + """업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다. + + ★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다. + 호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다.""" + if category != self.category.value: + return False + label = external_category or "" + return not any(word in label for word in self.exclude_external_categories) + + +def _strings(value, source: str, name: str, *, allow_empty: bool = False) -> tuple[str, ...]: + if not isinstance(value, list) or (not value and not allow_empty): + raise FaqCatalogError(f"{source}: '{name}' 은 비어 있지 않은 목록이어야 함") + if not all(isinstance(v, str) and v for v in value): + raise FaqCatalogError(f"{source}: '{name}' 에 문자열이 아닌 값 — {value}") + return tuple(value) + + +def _parse(doc: dict, source: str) -> FaqCatalog: + try: + category = PlaceCategory(doc.get("category")) + except ValueError as ex: + raise FaqCatalogError(f"{source}: PlaceCategory 에 없는 category={doc.get('category')}") from ex + schema = get_schema(category) + + answers = doc.get("fallback_answers") or {} + with_contact = _strings(answers.get("with_contact"), source, "with_contact") + without_contact = _strings(answers.get("without_contact"), source, "without_contact") + for text in with_contact + without_contact: + if "{topic}" not in text: + raise FaqCatalogError(f"{source}: 공통 답변에 {{topic}} 이 없음 — {text}") + if not all("{contact}" in text for text in with_contact): + raise FaqCatalogError(f"{source}: with_contact 답변에 {{contact}} 가 없음") + + items: list[CatalogItem] = [] + seen: set[str] = set() + for row in doc.get("items") or []: + for name in ("id", "question", "topic"): + if not isinstance(row.get(name), str) or not row[name].strip(): + raise FaqCatalogError(f"{source}: 항목 '{name}' 이 비었음 — {row}") + if row["id"] in seen: + raise FaqCatalogError(f"{source}: id 중복 — {row['id']}") + seen.add(row["id"]) + fact_keys = _strings(row.get("fact_keys"), source, f"{row['id']}.fact_keys", allow_empty=True) + unknown = [k for k in fact_keys if not schema.has(k)] + if unknown: + raise FaqCatalogError(f"{source}: {row['id']} 의 fact_keys 가 업종 스키마에 없음 — {unknown}") + keywords = _strings(row.get("keywords"), source, f"{row['id']}.keywords") + if any(" " in k for k in keywords): + raise FaqCatalogError(f"{source}: {row['id']} 의 keywords 에 공백 — 질문은 공백을 지우고 비교한다") + items.append(CatalogItem(row["id"], row["question"], row["topic"], fact_keys, + tuple(k.lower() for k in keywords))) + if not items: + raise FaqCatalogError(f"{source}: items 가 비었음") + + return FaqCatalog( + name=str(doc.get("name") or source), + label=str(doc.get("label") or ""), + category=category, + exclude_external_categories=_strings( + doc.get("exclude_external_categories") or [], source, "exclude_external_categories", allow_empty=True), + fallback_with_contact=with_contact, + fallback_without_contact=without_contact, + items=tuple(items), + ) + + +def load_catalogs() -> list[FaqCatalog]: + """리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등).""" + global _catalogs + if _catalogs is None: + loaded = [] + for path in sorted(_RESOURCE_DIR.glob("*.json")): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except Exception as ex: + raise FaqCatalogError(f"카탈로그 파일 로드 실패: {path}: {ex}") from ex + loaded.append(_parse(doc, path.name)) + _catalogs = loaded + return list(_catalogs) + + +def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None: + """이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다).""" + return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None) diff --git a/solution/backend/common/faq_catalog/resources/pension.json b/solution/backend/common/faq_catalog/resources/pension.json new file mode 100644 index 0000000..87b69ba --- /dev/null +++ b/solution/backend/common/faq_catalog/resources/pension.json @@ -0,0 +1,50 @@ +{ + "name": "pension", + "label": "펜션", + "category": 1, + "exclude_external_categories": ["호텔", "모텔", "리조트"], + "fallback_answers": { + "with_contact": [ + "{topic} {contact}로 문의해 주시면 안내해 드립니다.", + "{topic} {contact}로 연락 주시면 자세히 알려 드립니다.", + "{topic} 예약 전에 {contact}로 확인해 주세요." + ], + "without_contact": [ + "{topic} 예약 전에 숙소로 직접 문의 부탁드립니다.", + "{topic} 숙소에 문의해 주시면 안내해 드립니다.", + "{topic} 이용 전에 숙소로 확인 부탁드립니다." + ] + }, + "items": [ + {"id": "check_in", "question": "체크인은 몇 시부터인가요?", "topic": "체크인 시간", "fact_keys": ["check_in_time"], "keywords": ["체크인", "입실시간"]}, + {"id": "check_out", "question": "체크아웃은 몇 시까지인가요?", "topic": "체크아웃 시간", "fact_keys": ["check_out_time"], "keywords": ["체크아웃", "퇴실시간"]}, + {"id": "late_check_in", "question": "늦은 시간에 도착해도 입실할 수 있나요?", "topic": "늦은 시간 입실 가능 여부", "fact_keys": ["reception_hours"], "keywords": ["늦은시간", "늦게도착", "늦게입실", "늦은입실", "밤늦게"]}, + {"id": "luggage", "question": "입실 전이나 퇴실 후에 짐을 맡길 수 있나요?", "topic": "짐 보관 가능 여부", "fact_keys": [], "keywords": ["짐보관", "짐을맡", "짐맡"]}, + {"id": "checkout_cleaning", "question": "퇴실할 때 설거지나 분리수거를 해야 하나요?", "topic": "퇴실 시 정리 방법", "fact_keys": [], "keywords": ["설거지", "분리수거", "퇴실청소", "뒷정리"]}, + {"id": "cancel", "question": "예약을 취소하면 환불은 어떻게 되나요?", "topic": "취소·환불 규정", "fact_keys": ["cancel_policy"], "keywords": ["취소", "환불"]}, + {"id": "weekday_weekend_price", "question": "평일과 주말 요금이 다른가요?", "topic": "평일·주말 요금", "fact_keys": ["weekday_price", "weekend_price"], "keywords": ["평일", "주말요금"]}, + {"id": "peak_price", "question": "성수기 요금이 따로 있나요?", "topic": "성수기 요금", "fact_keys": ["peak_price"], "keywords": ["성수기"]}, + {"id": "deposit", "question": "보증금이 있나요?", "topic": "보증금 여부", "fact_keys": [], "keywords": ["보증금"]}, + {"id": "extra_costs", "question": "숙박 요금 외에 추가로 드는 비용이 있나요?", "topic": "숙박 요금 외 추가 비용", "fact_keys": ["extra_person_fee", "bbq_fee"], "keywords": ["추가비용", "추가로드는", "별도요금"]}, + {"id": "capacity", "question": "기준 인원과 최대 인원은 몇 명인가요?", "topic": "기준 인원과 최대 인원", "fact_keys": ["standard_capacity", "max_capacity"], "keywords": ["기준인원", "최대인원"]}, + {"id": "extra_person", "question": "인원을 추가하면 요금이 얼마인가요?", "topic": "인원 추가 요금", "fact_keys": ["extra_person_fee"], "keywords": ["인원추가", "추가인원", "인원을추가"]}, + {"id": "pet", "question": "반려동물과 함께 묵을 수 있나요?", "topic": "반려동물 동반 가능 여부", "fact_keys": ["pet_allowed"], "keywords": ["반려", "애견", "강아지"]}, + {"id": "baby", "question": "아기와 함께 가도 되나요? 유아용품이 있나요?", "topic": "유아 동반과 유아용품", "fact_keys": ["baby_amenities"], "keywords": ["아기", "유아"]}, + {"id": "room_types", "question": "어떤 객실(동)이 있나요?", "topic": "객실 구성", "fact_keys": ["room_type", "total_rooms", "building_scale"], "keywords": ["객실종류", "어떤객실", "객실구성"]}, + {"id": "bed", "question": "침대는 어떻게 구성되어 있나요?", "topic": "침대 구성", "fact_keys": ["bed_type"], "keywords": ["침대", "침구"]}, + {"id": "bathroom", "question": "객실에 욕실은 몇 개인가요?", "topic": "욕실 구성", "fact_keys": ["bathroom_count", "has_bathroom"], "keywords": ["욕실", "화장실"]}, + {"id": "toiletries", "question": "수건과 세면도구가 준비되어 있나요?", "topic": "수건·세면도구 제공 여부", "fact_keys": ["has_toiletries"], "keywords": ["수건", "세면도구", "어메니티"]}, + {"id": "aircon", "question": "냉난방이 되나요?", "topic": "냉난방 시설", "fact_keys": ["has_aircon"], "keywords": ["냉난방", "에어컨", "난방"]}, + {"id": "wifi", "question": "와이파이를 쓸 수 있나요?", "topic": "와이파이 이용 여부", "fact_keys": ["wifi", "has_internet"], "keywords": ["와이파이", "인터넷", "wifi"]}, + {"id": "view", "question": "객실에서 어떤 풍경이 보이나요?", "topic": "객실 전망", "fact_keys": ["view"], "keywords": ["풍경", "전망"]}, + {"id": "cooking", "question": "객실에서 요리할 수 있나요?", "topic": "객실 내 취사 가능 여부", "fact_keys": ["cooking_allowed", "has_kitchen"], "keywords": ["취사", "요리", "조리"]}, + {"id": "bbq", "question": "바비큐를 할 수 있나요?", "topic": "바비큐 이용 가능 여부", "fact_keys": ["bbq_available"], "keywords": ["바비큐", "바베큐"]}, + {"id": "bbq_fee", "question": "바비큐 요금과 제공되는 준비물은 무엇인가요?", "topic": "바비큐 요금과 준비물", "fact_keys": ["bbq_fee"], "keywords": ["바비큐요금", "바베큐요금", "바비큐비용", "숯"]}, + {"id": "breakfast", "question": "조식이 제공되나요?", "topic": "조식 제공 여부", "fact_keys": ["breakfast"], "keywords": ["조식", "아침식사"]}, + {"id": "pool", "question": "수영장이 있나요? 온수로 이용할 수 있나요?", "topic": "수영장·온수 이용 여부", "fact_keys": [], "keywords": ["수영장", "온수풀", "풀장"]}, + {"id": "smoking", "question": "흡연할 수 있는 곳이 있나요?", "topic": "흡연 가능 구역", "fact_keys": ["smoking"], "keywords": ["흡연", "담배"]}, + {"id": "parking", "question": "주차할 수 있나요? 몇 대까지 가능한가요?", "topic": "주차 가능 여부", "fact_keys": ["parking", "parking_capacity"], "keywords": ["주차"]}, + {"id": "pickup", "question": "대중교통으로 가면 픽업이 되나요?", "topic": "픽업 서비스", "fact_keys": ["pickup_service"], "keywords": ["픽업", "대중교통", "셔틀"]}, + {"id": "nearby", "question": "근처에 편의점이나 마트가 있나요?", "topic": "주변 편의시설", "fact_keys": [], "keywords": ["편의점", "마트"]} + ] +} diff --git a/solution/backend/common/job_errors.py b/solution/backend/common/job_errors.py new file mode 100644 index 0000000..26a23dc --- /dev/null +++ b/solution/backend/common/job_errors.py @@ -0,0 +1,18 @@ +"""재시도가 의미 없는 잡 실패. + +★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail). + 네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이 + 사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다. + 실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로 + 세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다. + +★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다. + 그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다. + +★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면 + 의존 방향이 뒤집힌다. +""" + + +class PermanentJobError(RuntimeError): + """다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다.""" 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/conftest.py b/solution/backend/conftest.py index 3e66d99..7e70da2 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -166,7 +166,7 @@ def fake_renderer(monkeypatch, tmp_path_factory): ★ 왜 필요한가 발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가 - 굽고, BUILD 잡은 그 결과 보고서를 기다린다(services/render_report.wait_for). + 굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site). 파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는 대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다. @@ -175,8 +175,9 @@ def fake_renderer(monkeypatch, tmp_path_factory): 두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다. """ import json + from pathlib import Path - from services import render_report, site_payload + from services import render_report, render_service, site_payload payload_dir = tmp_path_factory.mktemp("payloads") monkeypatch.setattr(site_payload, "payload_dir", lambda: payload_dir) @@ -220,20 +221,25 @@ def fake_renderer(monkeypatch, tmp_path_factory): for unit in payload.get("units") or []: facts.extend(unit.get("facts") or []) count += sum(1 for f in facts if f.get("type") == "text" and long(f.get("value"))) + # 문의 안내(sourceType=TEMPLATE)는 모든 가게에 같은 문구라 고유 콘텐츠가 아니다. count += sum( 1 for q in payload.get("faqs") or [] - if str(q.get("question") or "").strip() and long(q.get("answer")) + if q.get("sourceType") != 5 + and str(q.get("question") or "").strip() and long(q.get("answer")) ) count += sum(1 for m in payload.get("media") or [] if long(m.get("alt"))) return count - async def _wait_for(slug, site_version, timeout_sec, poll_sec=1.0): - path = payload_dir / f"{slug}.json" + async def _render_site(payload_path, site_version, timeout_sec): + path = Path(payload_path) + slug = path.stem if not path.is_file(): - return None + raise render_service.RenderFailed(f"payload 파일이 없다: {payload_path}") payload = json.loads(path.read_text(encoding="utf-8")) if payload["site"]["version"] != site_version: - return None + raise render_service.RenderFailed( + f"버전이 다르다(payload={payload['site']['version']} 요청={site_version})" + ) place = payload.get("place") or {} count = _count(payload) # ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** @@ -274,5 +280,8 @@ def fake_renderer(monkeypatch, tmp_path_factory): "error": None, } - monkeypatch.setattr(render_report, "wait_for", _wait_for) + monkeypatch.setattr(render_service, "render_site", _render_site) + async def _activate_site(slug, site_version): + return None + monkeypatch.setattr(render_service, "activate_site", _activate_site) return payload_dir diff --git a/solution/backend/crud/faq_crud.py b/solution/backend/crud/faq_crud.py index a470211..6de4445 100644 --- a/solution/backend/crud/faq_crud.py +++ b/solution/backend/crud/faq_crud.py @@ -89,7 +89,9 @@ class FaqCRUD(IFaqCRUD): .where( place_faqs.place_id == place_id, place_faqs.deleted == False, # noqa: E712 - place_faqs.generated_by == SourceType.LLM.value, + # 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다. + # 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다. + place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)), # 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — # 반려는 판단의 기록이라 재생성이 지울 이유가 없다. place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)), diff --git a/solution/backend/crud/job_crud.py b/solution/backend/crud/job_crud.py index 00fbafe..1318a96 100644 --- a/solution/backend/crud/job_crud.py +++ b/solution/backend/crud/job_crud.py @@ -83,7 +83,7 @@ class JobQueue: FOR UPDATE SKIP LOCKED LIMIT 1 ) - RETURNING job_id, job_type, payload, attempts, max_attempts + RETURNING job_id, job_type, payload, attempts, max_attempts, worker_id """) async def run(s): @@ -140,6 +140,25 @@ class JobQueue: return await self._tx(run) + async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool: + """재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다. + + ★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError). + 백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은 + 그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다.""" + sql = text(""" + UPDATE jobs SET status = 4, last_error = :err, + lease_until = NULL, worker_id = NULL, updated_at = now() + WHERE job_id = CAST(:id AS uuid) AND status = 2 AND worker_id = :wid + RETURNING job_id + """) + + async def run(s): + row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "err": error[:2000]})).first() + return row is not None + + return await self._tx(run) + # ---- lease 갱신(heartbeat) / 회수(reaper) ---- async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool: sql = text(""" @@ -176,11 +195,41 @@ class JobQueue: return await self._tx(run) # ---- 단건 조회 (상태 폴링) ---- + async def set_progress(self, job: dict, progress: dict) -> bool: + # 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다. + sql = text(""" + UPDATE jobs SET progress = CAST(:progress AS jsonb), updated_at = now() + WHERE job_id = CAST(:id AS uuid) AND status = 2 + AND worker_id = :wid AND attempts = :attempt + AND lease_until > now() + RETURNING job_id + """) + + async def run(s): + row = (await s.execute(sql, { + "id": job["job_id"], "wid": job["worker_id"], "attempt": job["attempts"], + "progress": json.dumps(progress), + })).first() + return row is not None + + return await self._tx(run) + + async def find_latest(self, dedupe_key: str) -> dict | None: + """복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다.""" + async def run(s): + row = (await s.execute(text(""" + SELECT job_id, status FROM jobs WHERE dedupe_key = :dk + ORDER BY created_at DESC, job_id DESC LIMIT 1 + """), {"dk": dedupe_key})).mappings().first() + return {**row, "job_id": str(row["job_id"])} if row else None + + return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) + async def get(self, job_id: str) -> dict | None: """잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값).""" sql = text(""" SELECT job_id, job_type, status, priority, attempts, max_attempts, - payload, result, last_error, run_after, run_started_at, created_at, updated_at + payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at FROM jobs WHERE job_id = CAST(:id AS uuid) """) @@ -190,7 +239,7 @@ class JobQueue: return None d = dict(row) d["job_id"] = str(d["job_id"]) - for key in ("payload", "result"): + for key in ("payload", "result", "progress"): if isinstance(d.get(key), str): d[key] = json.loads(d[key]) return d diff --git a/solution/backend/crud/place_content_crud.py b/solution/backend/crud/place_content_crud.py index ad501f0..37bd6e1 100644 --- a/solution/backend/crud/place_content_crud.py +++ b/solution/backend/crud/place_content_crud.py @@ -31,6 +31,7 @@ class PlaceContentCRUD: select( area_contents.local_content_id, area_contents.content_type, + area_contents.source, area_contents.external_id, area_contents.title, area_contents.body, diff --git a/solution/backend/crud/search_console_crud.py b/solution/backend/crud/search_console_crud.py new file mode 100644 index 0000000..b966bd9 --- /dev/null +++ b/solution/backend/crud/search_console_crud.py @@ -0,0 +1,57 @@ +"""발행 DB를 작업 원장으로 사용해 알림 적재 실패/재시작에도 대상을 다시 찾는다.""" +from sqlalchemy import select, or_, text +from sqlalchemy.dialects.postgresql import insert + +from common.database.model.models import sites, places, site_versions, site_search_status as Status +from common.enums import SiteStatus + + +def published_conditions(): + return ( + sites.deleted.is_(False), places.deleted.is_(False), + sites.status == SiteStatus.PUBLISHED.value, + sites.published_at.is_not(None), sites.current_version_id.is_not(None), + ) + + +async def lock_batch(session) -> bool: + # 여러 API 프로세스가 같은 크론을 등록해도 외부 호출은 한 곳만 수행한다. + return bool(await session.scalar(text("SELECT pg_try_advisory_xact_lock(734920151)"))) + + +async def new_publications(session, property_url: str): + result = await session.execute( + select(sites, places, site_versions) + .join(places, places.place_id == sites.place_id) + .join(site_versions, site_versions.site_version_id == sites.current_version_id) + .outerjoin(Status, Status.site_id == sites.site_id) + .where(*published_conditions(), site_versions.deleted.is_(False), or_( + Status.site_id.is_(None), Status.site_version_id != sites.current_version_id, + Status.property_url != property_url, Status.published_at != sites.published_at, + )) + .order_by(sites.published_at).limit(100) + ) + return result.all() + + +async def register(session, values: dict): + reset = dict(values, sitemap_submitted_at=None, inspected_at=None, first_indexed_at=None, + inspection=None, error_code=None, failures=0, alerted_at=None, + next_check_at=text("now()"), updated_at=text("now()"), deleted=False) + await session.execute(insert(Status).values(**values).on_conflict_do_update( + index_elements=[Status.site_id], set_=reset, + )) + + +async def due_sites(session, property_url: str): + result = await session.execute( + select(Status).join(sites, sites.site_id == Status.site_id) + .join(places, places.place_id == sites.place_id) + .where(*published_conditions(), Status.deleted.is_(False), + Status.site_version_id == sites.current_version_id, + Status.published_at == sites.published_at, Status.property_url == property_url, + Status.next_check_at <= text("now()")) + .order_by(Status.next_check_at).limit(5) + .with_for_update(of=Status) + ) + return result.scalars().all() diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 922af6b..c76df31 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,12 +5,34 @@ from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import places, site_publish_logs, site_versions, sites -from common.enums import BuildStatus, ErrorType, SiteStatus +from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites +from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus from common.logger import LOG from common.utils.gtime import GTime +def _primary_photo_subquery(): + """place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행). + + site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장, + sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다. + sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로 + 대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다.""" + return ( + select(place_photos.url) + .where( + place_photos.place_id == places.place_id, + place_photos.deleted == False, # noqa: E712 + place_photos.status == MediaStatus.APPROVED.value, + place_photos.unit_id.is_(None), + ) + .order_by(place_photos.sort_order.asc(), place_photos.created_at.asc()) + .limit(1) + .correlate(places) + .scalar_subquery() + ) + + # 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다. class ISiteCRUD(ABC): @abstractmethod @@ -23,6 +45,7 @@ class ISiteCRUD(ABC): @abstractmethod async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]: + """(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).""" pass @abstractmethod @@ -45,6 +68,10 @@ class ISiteCRUD(ABC): async def get_version(self, cdb: AsyncSession, site_id, site_version_id) -> Tuple[ErrorType, site_versions]: pass + @abstractmethod + async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]: + pass + @abstractmethod async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]: pass @@ -63,6 +90,7 @@ class ISiteCRUD(ABC): @abstractmethod async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]: + """(ErrorType, [(site, place, primary_photo_url)]).""" pass @@ -94,10 +122,16 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, None async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: - """사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). + """사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진. + (ErrorType, [(place, site, built_at, primary_photo_url)], 총건수). 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 - (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" + (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다. + + ★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌 + 첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때 + (Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다. + 여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다.""" try: where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712 @@ -107,7 +141,7 @@ class SiteCRUD(ISiteCRUD): total = int(cnt_rows[0] or 0) if cnt_rows else 0 query = ( - select(places, sites, site_versions.built_at) + select(places, sites, site_versions.built_at, _primary_photo_subquery()) .outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712 .outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id) .where(where) @@ -189,6 +223,31 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]: + """롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다. + + ★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고, + 그 번호가 곧 out/versions/// 디렉토리 이름이다(prerender.ts).""" + try: + query = ( + select(site_versions) + .where( + site_versions.site_id == site_id, + site_versions.version == version, + site_versions.deleted == False, # noqa: E712 + ) + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(rows) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, rows[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]: """빌드 결과를 버전에 기록한다(BUILT 또는 FAILED).""" try: @@ -236,14 +295,18 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, [] async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]: - """발행된 사이트 + 그 사업장을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다. + """발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다. + (ErrorType, [(site, place, primary_photo_url)]). ★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로 돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 — - 여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.""" + 여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다. + + ★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) — + sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다.""" try: query = ( - select(sites, places) + select(sites, places, _primary_photo_subquery()) .join(places, places.place_id == sites.place_id) .where( sites.status == SiteStatus.PUBLISHED.value, diff --git a/solution/backend/crud/social_crud.py b/solution/backend/crud/social_crud.py index 089c726..ee7d69c 100644 --- a/solution/backend/crud/social_crud.py +++ b/solution/backend/crud/social_crud.py @@ -2,6 +2,7 @@ import json from sqlalchemy import text +from common.enums import JobType from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_social_posts as Post @@ -41,7 +42,7 @@ async def decide(s, post_id, sha, approve, via): ) ).first() if row and approve and row.account_id: - await enqueue(s, post_id, 9) + await enqueue(s, post_id, JobType.SOCIAL_POST.value) return bool(row) diff --git a/solution/backend/requirements.txt b/solution/backend/requirements.txt index 3bb0611..2cc39e0 100644 --- a/solution/backend/requirements.txt +++ b/solution/backend/requirements.txt @@ -9,7 +9,10 @@ orjson pydantic>=2.0 python-multipart httpx +google-auth>=2.0 # Search Console 서비스 계정 인증 (선택 기능) +requests>=2.31 # google-auth 토큰 갱신 transport apscheduler>=3.10 pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식) azure-storage-blob>=12.19 cryptography>=42 # SNS 위임 토큰 Fernet 암호화(평문 저장 경로 없음) +playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요) diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index c08e700..ab1b6fc 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -15,6 +15,7 @@ import router.v1.place.place import router.v1.fact.fact import router.v1.faq.faq import router.v1.media.media +import router.v1.media.relay import router.v1.job.job import router.v1.site.site import router.v1.site.showcase @@ -92,6 +93,9 @@ app.include_router(router.v1.place.place.router) app.include_router(router.v1.fact.fact.router) app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) +# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 — +# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py). +app.include_router(router.v1.media.relay.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.my_router) diff --git a/solution/backend/router/v1/fact/protocol.py b/solution/backend/router/v1/fact/protocol.py index 91db197..a4c0b4d 100644 --- a/solution/backend/router/v1/fact/protocol.py +++ b/solution/backend/router/v1/fact/protocol.py @@ -75,6 +75,9 @@ class FactData(WebPacketProtocol): unit_id: Optional[uuid.UUID] = None key: str value: Optional[str] = None + # ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용, + # FactService._attach_summaries 가 요청마다 계산해 붙인다). + summary: Optional[str] = None unit: Optional[str] = None source_type: SourceType source_url: Optional[str] = None diff --git a/solution/backend/router/v1/job/job.py b/solution/backend/router/v1/job/job.py index 2c2ff2a..8654023 100644 --- a/solution/backend/router/v1/job/job.py +++ b/solution/backend/router/v1/job/job.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends +from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireDeveloper from services.job_service import JobService @@ -27,8 +28,8 @@ async def job_ops(service: JobService = Depends()): dependencies=[Depends(IsValidAccessToken)], summary="잡 상태 조회(폴링)", ) -async def get_job(job_id: UUID, service: JobService = Depends()): - return RemoveNoneResponse(await service.get_job(str(job_id))) +async def get_job(job_id: UUID, service: JobService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_job(str(job_id), user_info)) @router.post( diff --git a/solution/backend/router/v1/job/protocol.py b/solution/backend/router/v1/job/protocol.py index 1737d1d..33517a4 100644 --- a/solution/backend/router/v1/job/protocol.py +++ b/solution/backend/router/v1/job/protocol.py @@ -1,6 +1,7 @@ import uuid from datetime import datetime from typing import Any, Optional +from typing import Literal from common.enums import JobStatus, JobType from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -10,13 +11,26 @@ class JobProtocol(WebPacketProtocol): pass +class JobStep(WebPacketProtocol): + id: str + status: Literal["pending", "running", "done", "skipped", "failed"] + reason: Optional[str] = None + + +class JobProgress(WebPacketProtocol): + steps: list[JobStep] + attempt: int + + class JobData(WebPacketProtocol): job_id: uuid.UUID + place_id: Optional[uuid.UUID] = None job_type: JobType status: JobStatus attempts: int = 0 max_attempts: int = 3 result: Optional[Any] = None + progress: Optional[JobProgress] = None last_error: Optional[str] = None run_after: Optional[datetime] = None run_started_at: Optional[datetime] = None diff --git a/solution/backend/router/v1/media/relay.py b/solution/backend/router/v1/media/relay.py new file mode 100644 index 0000000..38697f3 --- /dev/null +++ b/solution/backend/router/v1/media/relay.py @@ -0,0 +1,87 @@ +"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다. + +★ 왜 필요한가 (2026-09-15, 실측) + 수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은 + `Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼** + `toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는 + 못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다. + +★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에 + 놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는 + 사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다. + +★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다: + · https 만 + · 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN) + · 응답이 이미지가 아니면 거절, 크기 상한 + · 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다 +""" +import ipaddress +from urllib.parse import urlparse + +import httpx +from fastapi import APIRouter, HTTPException, Query, Response + +from common.logger import LOG + +router = APIRouter(prefix="/v1/image", tags=["Image"]) + +# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다. +# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다. +_ALLOWED_SUFFIXES = ( + ".pstatic.net", + "tong.visitkorea.or.kr", + ".visitkorea.or.kr", + "upload.wikimedia.org", +) + +_MAX_BYTES = 8 * 1024 * 1024 +_TIMEOUT = httpx.Timeout(10.0, connect=5.0) +# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다. +_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"} + + +def _allowed(url: str) -> bool: + parsed = urlparse(url) + if parsed.scheme != "https" or not parsed.hostname: + return False + host = parsed.hostname.lower() + # 숫자 주소는 받지 않는다 — 사내망으로 향하는 통로를 만들지 않는다. + try: + ipaddress.ip_address(host) + return False + except ValueError: + pass + return any(host == suffix.lstrip(".") or host.endswith(suffix) for suffix in _ALLOWED_SUFFIXES) + + +@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다") +async def relay(url: str = Query(min_length=8, max_length=2000)): + """인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 + 것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다).""" + if not _allowed(url): + raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다") + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True) as client: + res = await client.get(url) + except httpx.HTTPError as ex: + LOG.w(f"[image] 중계 실패 {url}: {ex}") + raise HTTPException(status_code=502, detail="사진을 가져오지 못했습니다") + + # 리다이렉트로 allowlist 밖으로 나갔으면 거기서 끊는다. + if not _allowed(str(res.url)): + raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다") + + kind = (res.headers.get("content-type") or "").split(";")[0].strip().lower() + if res.status_code != 200 or not kind.startswith("image/"): + raise HTTPException(status_code=502, detail="사진이 아닙니다") + if len(res.content) == 0 or len(res.content) > _MAX_BYTES: + raise HTTPException(status_code=502, detail="사진 크기가 범위를 벗어났습니다") + + # 주소가 곧 내용이다(원본이 바뀌면 주소도 바뀐다) — 길게 캐시해서 중계 횟수를 줄인다. + return Response( + content=res.content, + media_type=kind, + headers={"cache-control": "public, max-age=604800, immutable"}, + ) diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 94e4898..4ed64be 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -59,6 +59,17 @@ class Req_VerifyPlaceByUrl(PlaceProtocol): url: str = "" + # ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. + # + # 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지 + # 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 — + # 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면 + # 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다). + # + # False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는 + # 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19). + reuse_existing: bool = True + class Req_UpdatePlace(PlaceProtocol): # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. @@ -246,7 +257,7 @@ class Req_StartCopy(PlaceProtocol): ★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다).""" - pass + resume: bool = False class Res_StartCopy(Res_WebPacketProtocol): diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 816980c..6fe22c1 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -36,6 +36,17 @@ class Res_StartBuild(Res_WebPacketProtocol): created: bool = True +class Req_Rollback(SiteProtocol): + """예전 버전으로 공개 주소를 되돌린다. + + ★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면 + (보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서 + 돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는 + 그대로 통과한다).""" + + target_version: int + + class SiteVersionData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) @@ -86,6 +97,8 @@ class MySiteData(WebPacketProtocol): template_id: Optional[str] = None published_at: Optional[datetime] = None # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). + # Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다 + # (site_service._my_site_row) — 이때는 `?v=` 가 없다. thumbnail_url: Optional[str] = None # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. needs_rebuild: bool = False @@ -228,9 +241,14 @@ class Res_SiteSlug(Res_WebPacketProtocol): class Req_SiteStatus(SiteProtocol): """발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — - 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.""" + 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다. - action: PublishAction = PublishAction.SUSPEND + ★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도 + (`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가 + '아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15). + 무엇을 할지는 부르는 쪽이 적는다.""" + + action: PublishAction class ShowcaseItem(WebPacketProtocol): diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index bbf8361..76dc87c 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -7,6 +7,7 @@ from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.site_service import SiteService from .protocol import ( + Req_Rollback, Req_SiteSlug, Req_SiteStatus, Req_SiteTemplate, @@ -178,6 +179,20 @@ async def site_preview( return JSONResponse(content=payload) +@router.post( + path="/version/rollback", + response_model=Res_StartBuild, + summary="예전 버전으로 되돌리기(비동기)", + description="target_version 을 다시 공개 주소로 내보낸다. 재수집·재생성을 하지 않는다 — " + "그 버전이 발행될 때 박제된 snapshot 을 그대로 다시 굽는다. 렌더러가 그 사이 " + "바뀌었을 수 있어 게이트는 다시 통과해야 한다. 실패하면 지금 공개된 버전은 그대로다.", +) +async def rollback_version( + place_id: UUID, req: Req_Rollback, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.start_rollback(user_info, str(place_id), req)) + + @router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록") async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.list_versions(user_info, str(place_id))) diff --git a/solution/backend/scheduler/__init__.py b/solution/backend/scheduler/__init__.py index 0c091a1..70f6bf0 100644 --- a/solution/backend/scheduler/__init__.py +++ b/solution/backend/scheduler/__init__.py @@ -3,7 +3,8 @@ 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. 등록된 잡 — - · SNS 승인 만료·중단 복구 : 5분 간격 (scheduler/jobs.sweep_social_posts) + · Search Console 색인 점검 : 10분 간격 (GSC_ENABLED=1 일 때만) + · SNS 승인 만료·중단 복구 : 5분 간격 (scheduler/jobs.sweep_social_posts) 붙을 잡 — · 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신 @@ -36,14 +37,21 @@ def start_scheduler(): # 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다. _scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + if os.environ.get("GSC_ENABLED") == "1": + from services.search_console_service import run_scheduled_check + _scheduler.add_job(run_scheduled_check, "interval", minutes=10, + id="search-console", max_instances=1, coalesce=True) + # ★ 1분이 아니라 5분이다. 이 스윕이 하는 일은 "만료 표시" 와 "중단된 초안 정리" 뿐이라 # 분 단위 정밀도가 필요 없고, 주기가 짧으면 쓰기 커넥션을 계속 집어 든다 — # 실측(2026-09-14): 1분 주기로 두자 같은 컨테이너에서 도는 테스트가 커넥션을 못 받아 # TimeoutError 로 무더기 실패했다. 운영에서도 같은 풀을 발행·수집과 나눠 쓴다. from scheduler.jobs import sweep_social_posts - _scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True) + _scheduler.add_job(sweep_social_posts, "interval", minutes=5, + id="social-sweep", max_instances=1, coalesce=True) + _scheduler.start() - LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)") + LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)") def shutdown_scheduler(): diff --git a/solution/backend/scripts/apply_crawled_facts.py b/solution/backend/scripts/apply_crawled_facts.py new file mode 100644 index 0000000..97b2123 --- /dev/null +++ b/solution/backend/scripts/apply_crawled_facts.py @@ -0,0 +1,73 @@ +"""기존 크롤링 후보를 현재 기록 정책으로 다시 적용한다. --apply 없이는 조회만 한다.""" +import argparse +import asyncio +import json +import sys +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import place_facts, places +from common.enums import DBWRType, ErrorType, FactStatus, SourceType +from common.models.gmodel import UserInfo +from crud.fact_crud import FactCRUD +from crud.place_crud import PlaceCRUD +from router.v1.fact.protocol import Req_UpsertFact +from services.fact_service import FactService + + +async def main(place_id, apply): + try: + err, rows = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute(s, select(places).where( + places.place_id == place_id, places.deleted.is_(False))), + ) + if err != ErrorType.SUCCESS or len(rows or []) != 1: + raise RuntimeError('사업장 조회 실패') + place = rows[0] + err, facts = await DB_SESSION_MNG.execute_lambda( + place_facts.DBType(), DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute(s, select(place_facts).where( + place_facts.place_id == place_id, place_facts.deleted.is_(False), + place_facts.source_type == SourceType.CRAWL.value, + place_facts.status.in_([FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value]), + ).order_by(place_facts.collected_at.desc())), + ) + if err != ErrorType.SUCCESS: + raise RuntimeError('크롤링 후보 조회 실패') + service = FactService(FactCRUD(), PlaceCRUD()) + actor = UserInfo(user_id=str(place.owner_user_id), id='crawl-policy', role=1) + seen = set() + result = [] + for fact in facts or []: + key = (fact.unit_id, fact.key) + if key in seen: + continue + seen.add(key) + row = {'unitId': str(fact.unit_id), 'key': fact.key, 'value': fact.value} + if apply: + res = await service.upsert_fact(actor, str(place_id), Req_UpsertFact( + key=fact.key, value=fact.value, unit_id=fact.unit_id, + source_type=SourceType.CRAWL, source_url=fact.source_url, + expires_at=fact.expires_at, + )) + if not res.result.success: + raise RuntimeError(f'{fact.key}: {res.result.desc}') + row['status'] = res.fact.status + result.append(row) + print(json.dumps({'applied': apply, 'place': place.name, 'facts': result}, + ensure_ascii=False, default=str)) + finally: + await DB_SESSION_MNG.dispose_all() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--place-id', required=True, type=uuid.UUID) + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + asyncio.run(main(args.place_id, args.apply)) diff --git a/solution/backend/scripts/generate_lodging_catchphrases.py b/solution/backend/scripts/generate_lodging_catchphrases.py new file mode 100644 index 0000000..a71a881 --- /dev/null +++ b/solution/backend/scripts/generate_lodging_catchphrases.py @@ -0,0 +1,42 @@ +"""숙박 공통 감성 문구를 한 번 생성한다. 방문 시에는 저장된 문구만 순환한다.""" +import asyncio +import json +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from services.llm.gemini import DEFAULT_MODEL, call, extract_text + +PROMPT = """여러 숙박업체 홈페이지 첫 화면에서 돌아가며 보여 줄 한국어 감성 문구 10개를 작성하세요. +호텔, 모텔, 펜션, 게스트하우스 어디에나 쓸 수 있어야 합니다. +휴식, 여행, 머무는 시간, 일상에서의 잠깐의 쉼을 담아 담백하고 따뜻하게 쓰세요. +각 문구는 공백 포함 15~30자. 과장, 진부한 최상급, 상호명, 지명, 계절, 날씨, +바다/숲/정원/독채 등 시설이나 경관, 청결/조식/가격 등 확인이 필요한 사실과 서비스 약속은 금지합니다. +서로 다른 표현으로 정확히 10개, 문자열 JSON 배열만 반환하세요.""" + + +async def main(): + async with httpx.AsyncClient(timeout=90) as client: + response = await call(client, DEFAULT_MODEL, { + "contents": [{"role": "user", "parts": [{"text": PROMPT}]}], + "generationConfig": { + "responseMimeType": "application/json", + "responseSchema": {"type": "ARRAY", "items": {"type": "STRING"}, "minItems": 10, "maxItems": 10}, + "temperature": 0.8, + }, + }, max_retries=0) + lines = json.loads(extract_text(response)) + if not isinstance(lines, list) or len(lines) != 10 or not all(isinstance(s, str) and 15 <= len(s.strip()) <= 30 for s in lines): + raise ValueError("문구 개수·길이 검증 실패") + lines = [s.strip() for s in lines] + if len(set(lines)) != 10: + raise ValueError("중복 문구") + target = Path(__file__).resolve().parents[2] / "site/src/lib/lodging-catchphrases.json" + target.write_text(json.dumps(lines, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"model": DEFAULT_MODEL, "items": lines}, ensure_ascii=False)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/solution/backend/scripts/import_yanolja_test_place.py b/solution/backend/scripts/import_yanolja_test_place.py new file mode 100644 index 0000000..731b2d2 --- /dev/null +++ b/solution/backend/scripts/import_yanolja_test_place.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트. +================================================================= + +★ 운영 파이프라인이 아니다 — services/collector/registry.py 에 등록하지 않고, + worker 잡큐에도 연결하지 않는다. 개발자가 수동으로 실행하는 CLI 전용. + (registry.py·static_html_adapter.py 의 야놀자 관련 구조적 차단은 그대로 유효하다 — + 이 스크립트는 그 결론을 뒤집지 않는다. 테스트 목적 한정, 실제 배포 전 별도 협의 필요.) + +동작 + 1. place_id 로 places 테이블에서 업장을 조회한다. 주소는 따로 입력받지 않고 + 그 업장의 road_address(없으면 address)를 그대로 검색어로 쓴다. + 2. verified_at 이 비어 있으면 중단한다(운영 run_collect 와 같은 가드 — + 동일 업소 검증 전에는 수집하지 않는다). + 3. scripts/yanolja_search_and_crawl.py 로 그 주소를 검색·크롤링한다. + 4. 결과를 CollectedFact/CollectedMedia 로 매핑해서, 기존 collect_service.py 의 + ensure_units/store_facts/store_media 를 그대로 호출한다 — FactService 를 그대로 + 통과하므로 새 값은 UNVERIFIED/PENDING_OWNER/PENDING_REVIEW 로만 만들어진다. + 이 상태를 이 스크립트가 직접 VERIFIED/APPROVED 로 바꾸는 일은 없다. + +사용법 (solution/backend 에서, 가상환경 안에서) + python scripts/import_yanolja_test_place.py --place-id (dry-run: 매핑만 출력) + python scripts/import_yanolja_test_place.py --place-id --commit (실제 DB 적재) + + PGSSLMODE=disable DB_PASSWORD=... 를 dev-env-quirks 메모대로 주입해야 한다(APP_ENV=local 기본). +""" +from __future__ import annotations + +import argparse +import asyncio +import os +import re +import sys +import uuid + +# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를 +# sys.path 에 넣어야 common/services 등을 import 할 수 있다. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("APP_ENV", "local") + +from playwright.sync_api import sync_playwright # noqa: E402 +from sqlalchemy import select # noqa: E402 + +from scripts.yanolja_search_and_crawl import StayData, search_and_crawl # noqa: E402 + +from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402 +from common.database.model.models import places # noqa: E402 +from common.enums import DBWRType, ErrorType # noqa: E402 +from services.collector.base import CollectedFact, CollectedMedia, RawSource # noqa: E402 +from services.collect_service import ensure_units, store_facts, store_media # noqa: E402 + + +def _parse_capacity(capacity: str | None) -> tuple[str | None, str | None]: + """'기준 2인 / 최대 4인' → ('2', '4').""" + if not capacity: + return None, None + std_m = re.search(r"기준\s*(\d+)인", capacity) + max_m = re.search(r"최대\s*(\d+)인", capacity) + return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None) + + +def stay_data_to_source(data: StayData) -> RawSource: + """StayData → RawSource(facts/media). lodging.json 의 scope=unit 필드 중 + 크롤러가 실제로 채울 수 있는 것만 매핑한다(room_type/standard_capacity/max_capacity 는 + required=true 라 반드시 채운다. weekday_price 등 요금은 크롤러가 안 모으므로 비워둔다). + """ + facts: list[CollectedFact] = [] + media: list[CollectedMedia] = [] + + if data.overview.strip(): + facts.append(CollectedFact(key="intro", value=data.overview.strip(), scope="place")) + for url in data.photos: + media.append(CollectedMedia(origin_url=url, label="갤러리")) + + for room in data.rooms: + std_cap, max_cap = _parse_capacity(room.capacity) + facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name)) + if std_cap: + facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name)) + if max_cap: + facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name)) + if room.bed: + facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name)) + if room.description: + facts.append(CollectedFact(key="room_intro", value=room.description, scope="unit", unit_name=room.name)) + for url in room.images: + media.append(CollectedMedia(origin_url=url, label="객실 사진", unit_name=room.name)) + + return RawSource(url=data.url, adapter_id="yanolja_test_import", facts=facts, media=media) + + +def _print_preview(source: RawSource) -> None: + print(f"\n[미리보기] {source.url}") + place_facts = [f for f in source.facts if f.scope == "place"] + print(f" place-scope fact {len(place_facts)}건: {[f.key for f in place_facts]}") + for unit_name in source.unit_names(): + unit_facts = [f for f in source.facts if f.unit_name == unit_name] + unit_media = [m for m in source.media if m.unit_name == unit_name] + print(f" [{unit_name}] fact {len(unit_facts)}건, 사진 {len(unit_media)}장") + for f in unit_facts: + print(f" {f.key} = {f.value}") + place_media = [m for m in source.media if m.unit_name is None] + print(f" place-scope 사진 {len(place_media)}장") + + +async def _load_place(place_id: str): + stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712 + err, row = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute(s, stmt), + ) + if err != ErrorType.SUCCESS or not row: + return None + return row[0] + + +def _crawl_sync(address: str, headful: bool) -> StayData: + """동기 Playwright API는 asyncio 루프가 도는 스레드에서 못 쓴다 — 별도 스레드에서 실행한다.""" + with sync_playwright() as p: + browser = p.chromium.launch(headless=not headful) + context = browser.new_context( + locale="ko-KR", + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + ), + ) + page = context.new_page() + try: + return search_and_crawl(page, address) + finally: + browser.close() + + +async def run(place_id: str, commit: bool, headful: bool) -> None: + place = await _load_place(place_id) + if place is None: + raise SystemExit(f"place를 찾을 수 없습니다: {place_id}") + if place.verified_at is None: + raise SystemExit( + "동일 업소 검증(verify) 전에는 수집하지 않습니다 — 운영 collect_service.run_collect 와 같은 가드." + ) + + address = place.road_address or place.address + if not address: + raise SystemExit("이 place에는 주소가 없습니다.") + + print(f"[대상] {place.name} ({place_id}) — 검색 주소: {address}") + + data = await asyncio.to_thread(_crawl_sync, address, headful) + + print(f"[크롤링 완료] {data.name} — 객실 {len(data.rooms)}개, 갤러리 사진 {len(data.photos)}장") + source = stay_data_to_source(data) + _print_preview(source) + + if not commit: + print("\n(dry-run) DB에는 쓰지 않았습니다. 실제로 저장하려면 --commit 을 주세요.") + await DB_SESSION_MNG.dispose_all() + return + + from common.models.gmodel import UserInfo + + actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_import", role=1) + + unit_map = await ensure_units(place_id, [source]) + facts_stat = await store_facts(actor, place_id, [source], unit_map) + media_stat = await store_media(place_id, [source], unit_map) + + print(f"\n[적재 완료] unit {len(unit_map)}개, fact={facts_stat}, media={media_stat}") + await DB_SESSION_MNG.dispose_all() + + +def main() -> None: + parser = argparse.ArgumentParser(description="야놀자 크롤링 → 테스트 place 객실 데이터 임포트(1회성)") + parser.add_argument("--place-id", required=True, help="대상 place_id(UUID)") + parser.add_argument("--commit", action="store_true", help="실제로 DB에 적재(기본은 dry-run)") + parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)") + args = parser.parse_args() + asyncio.run(run(args.place_id, args.commit, args.headful)) + + +if __name__ == "__main__": + main() diff --git a/solution/backend/scripts/pin_gunsan_hanilok.py b/solution/backend/scripts/pin_gunsan_hanilok.py new file mode 100644 index 0000000..5cbf78d --- /dev/null +++ b/solution/backend/scripts/pin_gunsan_hanilok.py @@ -0,0 +1,94 @@ +"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다. + +external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다. +네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다. +""" +import argparse +import asyncio +import json +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine import URL +from sqlalchemy.ext.asyncio import create_async_engine + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import area_contents, places +from common.enums import LocalContentStatus, LocalContentType, LocalSource +from config.server_configs import main_db_config as cfg +from services.site_payload import _local +from services.snapshot import _local_contents + +REGION = '52군산시' +NAVER_ID = '11861452' +CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452') + + +async def main(apply: bool): + engine = create_async_engine(URL.create( + 'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw, + host=cfg.write_host, port=cfg.write_port, database=cfg.name, + )) + try: + async with engine.begin() as conn: + rows = (await conn.execute(select( + area_contents.local_content_id, area_contents.title, area_contents.body, + ).where( + area_contents.region_code == REGION, + area_contents.kind == 'restaurant', area_contents.external_id.is_(None), + area_contents.deleted.is_(False), + ))).mappings().all() + if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows): + raise RuntimeError('다른 군산 공통 맛집이 이미 있어 덮어쓰지 않습니다.') + content_id = rows[0]['local_content_id'] if rows else CONTENT_ID + now = datetime.now(timezone.utc) + values = dict( + local_content_id=content_id, region_code=REGION, + content_type=LocalContentType.RESTAURANT.value, kind='restaurant', + # 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다. + source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥', + body={**(rows[0]['body'] if rows else {}), + 'name': '한일옥', 'searchQuery': '군산 한일옥', + 'naverPlaceId': NAVER_ID, + 'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home', + 'registration': 'owner_confirmed_region_default'}, + status=LocalContentStatus.PUBLISHED.value, published_at=now, + collected_at=now, display_start_at=None, display_end_at=None, + expires_at=None, deleted=False, updated_at=now, + ) + if apply: + await conn.execute(insert(area_contents).values(**values).on_conflict_do_update( + index_elements=[area_contents.local_content_id], + set_={k: v for k, v in values.items() if k != 'local_content_id'}, + )) + targets = (await conn.execute(select(places.place_id, places.name).where( + places.deleted.is_(False), places.region_code == REGION, + ))).mappings().all() + print(json.dumps({'applied': apply, 'database': cfg.name, 'region': REGION, + 'contentId': str(content_id), 'naverPlaceId': NAVER_ID, + 'existingPlaces': [dict(r) for r in targets]}, + default=str, ensure_ascii=False)) + + if apply: + # place_id 없는 새 군산 업장도 지역 공통 경로만으로 받는지 확인한다. + snapshot = await _local_contents(SimpleNamespace(region_code=REGION)) + local, _ = _local(snapshot, None, None) + matches = [r for r in local['restaurants'] if r['name'] == '한일옥'] + assert len(matches) == 1, '지역 공통 payload에 한일옥이 정확히 한 번 있어야 합니다.' + print(json.dumps({'verifiedRegionalPayload': matches}, ensure_ascii=False)) + finally: + await engine.dispose() + await DB_SESSION_MNG.dispose_all() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apply', action='store_true') + asyncio.run(main(parser.parse_args().apply)) diff --git a/solution/backend/scripts/search_console_status.py b/solution/backend/scripts/search_console_status.py new file mode 100644 index 0000000..518195d --- /dev/null +++ b/solution/backend/scripts/search_console_status.py @@ -0,0 +1,32 @@ +"""저장된 관측값만 출력한다. Google 호출/색인 요청은 하지 않는다.""" +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import site_search_status as Status +from common.enums import DBType + + +async def main(): + async def read(session): + rows = (await session.execute(select(Status).where(Status.deleted.is_(False)) + .order_by(Status.published_at.desc()))).scalars().all() + return [{"url": row.page_url, "published_at": row.published_at, + "sitemap_submitted_at": row.sitemap_submitted_at, + "inspected_at": row.inspected_at, "first_indexed_at": row.first_indexed_at, + "inspection": row.inspection, "error_code": row.error_code, + "next_check_at": row.next_check_at, "alerted_at": row.alerted_at} for row in rows] + try: + rows = await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, read) + print(json.dumps(rows, ensure_ascii=False, indent=2, default=str)) + finally: + await DB_SESSION_MNG.dispose_all() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/solution/backend/scripts/verify_yanolja_test_place.py b/solution/backend/scripts/verify_yanolja_test_place.py new file mode 100644 index 0000000..72a3653 --- /dev/null +++ b/solution/backend/scripts/verify_yanolja_test_place.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +테스트 place에 야놀자 크롤링으로 넣은 객실 fact/사진을 **수동으로 검수·승인**해서, +실제 미리보기 렌더러(site_payload.to_site_payload → UnitsSection)에 뜨는지 확인하는 +1회성 스크립트. + +★ 이것도 운영 파이프라인이 아니다 — 사람이 눈으로 확인하려고 이번 테스트 place 하나에만 + 쓰는 수동 검수 도구다. 실제 서비스에서 크롤링 값을 이렇게 자동 승인하면 안 된다 + (fact는 사람이 [맞아요]를 눌러야, 사진은 Vision/사람이 봐야 승인 상태가 된다). + +사용법 (solution/backend 에서, 가상환경 안): + PGSSLMODE=disable DB_PASSWORD=... python scripts/verify_yanolja_test_place.py --place-id +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import uuid + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("APP_ENV", "local") + +from sqlalchemy import select # noqa: E402 + +from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402 +from common.database.model.models import places, place_facts, place_photos # noqa: E402 +from common.enums import DBWRType, ErrorType, FactStatus, MediaStatus # noqa: E402 +from common.models.gmodel import UserInfo # noqa: E402 +from common.utils.gtime import GTime # noqa: E402 +from crud.fact_crud import FactCRUD # noqa: E402 +from crud.media_crud import MediaCRUD # noqa: E402 +from crud.place_crud import PlaceCRUD # noqa: E402 +from crud.site_crud import SiteCRUD # noqa: E402 +from crud.job_crud import JobQueue # noqa: E402 +from router.v1.fact.protocol import Req_TransitionFact # noqa: E402 +from services.fact_service import FactService # noqa: E402 +from services.site_service import SiteService # noqa: E402 + +# 검수 전 상태(우리가 이번에 넣은 값들)만 건드린다. +_PENDING_FACT_STATUSES = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + + +async def _load_place(place_id: str): + stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712 + err, row = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt) + ) + if err != ErrorType.SUCCESS or not row: + return None + return row[0] + + +async def _unit_scope_facts(place_id: str): + stmt = select(place_facts).where( + place_facts.place_id == uuid.UUID(place_id), + place_facts.deleted == False, # noqa: E712 + place_facts.unit_id.is_not(None), + place_facts.status.in_(_PENDING_FACT_STATUSES), + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt) + ) + return rows if err == ErrorType.SUCCESS else [] + + +async def _unit_scope_pending_photos(place_id: str): + stmt = select(place_photos).where( + place_photos.place_id == uuid.UUID(place_id), + place_photos.deleted == False, # noqa: E712 + place_photos.unit_id.is_not(None), + place_photos.status == MediaStatus.PENDING_REVIEW.value, + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + place_photos.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt) + ) + return rows if err == ErrorType.SUCCESS else [] + + +async def run(place_id: str) -> None: + place = await _load_place(place_id) + if place is None: + raise SystemExit(f"place를 찾을 수 없습니다: {place_id}") + + actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_verify", role=1) + fact_service = FactService(FactCRUD(), PlaceCRUD()) + media_crud = MediaCRUD() + + facts = await _unit_scope_facts(place_id) + print(f"[검수 대상 fact] {len(facts)}건") + verified = 0 + for f in facts: + res = await fact_service.transition( + actor, place_id, str(f.fact_id), Req_TransitionFact(status=FactStatus.VERIFIED) + ) + if res.result.success: + verified += 1 + else: + print(f" ! {f.key}({f.fact_id}) 전이 실패: {res.result.desc}") + print(f" -> VERIFIED 처리 {verified}/{len(facts)}건") + + photos = await _unit_scope_pending_photos(place_id) + print(f"[승인 대상 사진] {len(photos)}건") + approved = 0 + now = GTime.UTC() + for p in photos: + alt_text = p.label or "객실 사진" + run_err, _rowcount = await DB_SESSION_MNG.execute_lambda_claim( + place_photos.DBType(), + lambda s, m=p.media_id, a=alt_text: media_crud.apply_vision( + s, m, p.label, a, None, MediaStatus.APPROVED.value, now + ), + ) + if run_err == ErrorType.SUCCESS: + approved += 1 + print(f" -> APPROVED 처리 {approved}/{len(photos)}건 (alt_text 채움)") + + site_service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue()) + payload = await site_service.preview_payload(actor, place_id) + units = (payload or {}).get("units", []) + print(f"\n[/preview 재확인] units {len(units)}개") + for u in units: + print(f" - {u.get('name')}: fact {len(u.get('facts', []))}개, media {len(u.get('mediaIds', []))}장") + if not units: + print(" (여전히 비어 있습니다 — payload 구조나 상태값을 다시 확인하세요)") + print(json.dumps(payload, ensure_ascii=False, default=str)[:2000]) + + await DB_SESSION_MNG.dispose_all() + + +def main() -> None: + parser = argparse.ArgumentParser(description="테스트 place의 야놀자 객실 데이터를 검수·승인해서 미리보기에 반영") + parser.add_argument("--place-id", required=True) + args = parser.parse_args() + asyncio.run(run(args.place_id)) + + +if __name__ == "__main__": + main() diff --git a/solution/backend/scripts/yanolja_search_and_crawl.py b/solution/backend/scripts/yanolja_search_and_crawl.py new file mode 100644 index 0000000..7b73882 --- /dev/null +++ b/solution/backend/scripts/yanolja_search_and_crawl.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러 +========================================== + +★ 운영 collector 파이프라인(services/collector/registry.py)에 등록되지 않은 수동 도구다. + registry.py·static_html_adapter.py 에 야놀자를 구조적으로 막아둔 이유(HTTP 403 회피 + 목적의 봇 탐지 우회 금지, 재게시 관련 민사 판례)는 그대로 유효하다. 이 스크립트는 + 개발자가 필요할 때 수동으로만 실행하는 진단·백필용 도구이며, worker/collect_service + 에서 자동으로 호출되지 않는다. + +동작 순서 + 1. https://nol.yanolja.com/ 접속 + 2. 검색창에 주소를 입력하고 검색 실행 + 3. 검색결과 리스트에서 첫번째 업체(숙소)의 상세페이지 href 를 읽어 바로 이동 + 4. 이동한 상세페이지에서 객실/숙소소개/시설·서비스/이용안내/예약공지 + 객실별 사진을 크롤링 + +사전 준비 + pip install playwright + playwright install chromium + +사용법 (solution/backend 에서) + python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" -o result.json + python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --print + python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --headful + +주의 + - 검색 페이지, 상세페이지 모두 Next.js 기반 CSR 페이지라서 Playwright로 + 실제 브라우저를 띄워 렌더링을 끝낸 뒤 DOM에서 데이터를 추출한다. + - 페이지 구조(클래스명 등)는 야놀자 쪽에서 언제든 바뀔 수 있다. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from typing import Optional + +from playwright.sync_api import Page, TimeoutError as PWTimeoutError, sync_playwright + +SEARCH_URL = "https://nol.yanolja.com/" + +# 검색결과 카드는 +RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]' + +# 각 탭이 스크롤/렌더링되는 실제 섹션 id (2026-09 기준 확인됨) +SECTION_IDS = { + "rooms": "PLACE_SECTION", # 객실선택 + "overview": "OVERVIEW_SECTION", # 숙소소개 + "service": "SERVICE_SECTION", # 시설/서비스 + "policy": "POLICY_SECTION", # 이용안내 + "reservation": "RESERVATION_SECTION", # 예약공지 +} + + +@dataclass +class RoomInfo: + name: str + description: Optional[str] = None + capacity: Optional[str] = None # 예: "기준 2인 / 최대 4인" + bed: Optional[str] = None # 예: "킹 침대 1개" + raw_text: str = "" + images: list[str] = field(default_factory=list) # 객실 사진 URL(아이콘 제외) + + +@dataclass +class StayData: + url: str + stay_id: Optional[str] = None + name: Optional[str] = None + search_address: Optional[str] = None + picked_name: Optional[str] = None + rooms: list[RoomInfo] = field(default_factory=list) + overview: str = "" # 숙소 소개 + service: str = "" # 시설/서비스 + policy: str = "" # 이용 안내 + reservation: str = "" # 예약 공지 + photos: list[str] = field(default_factory=list) # 숙소소개 갤러리 사진 URL(있으면) + + +def search_and_pick_first(page: Page, address: str, timeout_ms: int = 15000) -> tuple[str, Optional[str]]: + """주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다. + + 카드를 클릭하지 않고 href를 직접 읽어 page.goto()로 이동한다. 클릭 시 + 새 탭이 뜨거나 배너/모달에 클릭이 가로채이는 문제를 피하기 위함이다. + """ + page.goto(SEARCH_URL, wait_until="domcontentloaded") + + search_box = page.get_by_role("combobox", name="검색어 입력") + search_box.click() + search_box.fill(address) + search_box.press("Enter") + + try: + first_card = page.locator(RESULT_CARD_SELECTOR).first + first_card.wait_for(state="visible", timeout=timeout_ms) + except PWTimeoutError: + raise RuntimeError(f"'{address}' 검색결과를 찾지 못했습니다.") + + name = first_card.get_attribute("aria-label") or first_card.inner_text() + detail_url = first_card.get_attribute("href") + if not detail_url: + raise RuntimeError("검색결과 카드에서 href를 찾지 못했습니다.") + + page.goto(detail_url, wait_until="domcontentloaded") + return page.url, name # page.url: href가 상대경로여도 절대 URL로 해석된 값 + + +def _extract_stay_id(url: str) -> Optional[str]: + m = re.search(r"/stay/domestic/(\d+)", url) + return m.group(1) if m else None + + +def _scroll_through_page(page: Page) -> None: + """지연 로딩(lazy render) 섹션들이 모두 렌더링되도록 페이지를 끝까지 스크롤.""" + prev_height = -1 + for _ in range(30): + page.mouse.wheel(0, 1200) + page.wait_for_timeout(250) + cur_height = page.evaluate("document.body.scrollHeight") + if cur_height == prev_height: + break + prev_height = cur_height + page.evaluate("window.scrollTo(0, 0)") + + +def _get_section_text(page: Page, element_id: str) -> str: + try: + page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + if (el) el.scrollIntoView({{block: 'center'}}); + }}""" + ) + page.wait_for_timeout(400) + return page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + return el ? el.innerText : ''; + }}""" + ) or "" + except Exception: + return "" + + +# img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... 의 침대·와이파이 +# 아이콘, no-image 플레이스홀더 등)은 제외한다. +def _is_real_photo_url(src: str) -> bool: + return bool(src) and "static/images" not in src + + +def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]: + """PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의

(객실명) 앞에 나오는 + 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다. + + 카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 + 쌓인 이미지가 그 h2 의 몫이다. + """ + try: + page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + if (el) el.scrollIntoView({{block: 'center'}}); + }}""" + ) + page.wait_for_timeout(400) + raw = page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + if (!el) return []; + const nodes = el.querySelectorAll('img, h2'); + const result = []; + let buf = []; + for (const n of nodes) {{ + if (n.tagName === 'IMG') {{ + if (n.src) buf.push(n.src); + }} else if (n.tagName === 'H2') {{ + result.push({{name: n.textContent.trim(), images: [...new Set(buf)]}}); + buf = []; + }} + }} + return result; + }}""" + ) or [] + except Exception: + return [] + return [ + (item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) + for item in raw + ] + + +def _get_section_images(page: Page, element_id: str) -> list[str]: + """섹션 안의 사진 URL을 순서대로(중복 제거, 아이콘 제외).""" + try: + page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + if (el) el.scrollIntoView({{block: 'center'}}); + }}""" + ) + page.wait_for_timeout(400) + srcs = page.evaluate( + f"""() => {{ + const el = document.getElementById({json.dumps(element_id)}); + if (!el) return []; + return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))]; + }}""" + ) or [] + except Exception: + return [] + return [u for u in srcs if _is_real_photo_url(u)] + + +def _get_stay_name(page: Page) -> Optional[str]: + try: + h1 = page.query_selector("h1") + return h1.inner_text().strip() if h1 else None + except Exception: + return None + + +def _parse_rooms_from_section_text(section_text: str) -> list[RoomInfo]: + """ + PLACE_SECTION.innerText 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다: + + 1 + / + 10 + B동 + (모던한 현대식 컨셉으로 꾸며진 따뜻한 공간) + 기준 2인 / 최대 4인 + 킹 침대 1개 + 숙박 + 체크인 + 15:00 + ~ 체크아웃 + 11:00 + 취소 및 환불 불가 + 상세보기 + 198,000 + 원 + NOL 머니 결제 시 최대 3,960P 적립 + 예약하기 + + 우리가 필요한 건 이름/설명/기준·최대인원/침대 뿐이므로 가격 이하는 무시한다. + 사이트 구조가 바뀌면 이 정규식도 함께 손봐야 한다. + """ + rooms: list[RoomInfo] = [] + # "N / M" (사진 장수) 로 카드 시작 지점을 나눈다 + chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text) + for chunk in chunks[1:]: # 첫 chunk는 "객실 선택" 타이틀 등 헤더 + lines = [l.strip() for l in chunk.split("\n") if l.strip()] + if not lines: + continue + name = lines[0] + capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk) + bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk) + # 이름과 "기준 N인" 줄 사이의 줄들을 설명으로 간주 + desc_lines = [] + for l in lines[1:]: + if l.startswith("기준") or "체크인" in l or l == "숙박": + break + desc_lines.append(l.strip("()")) + rooms.append( + RoomInfo( + name=name, + description=" ".join(desc_lines) if desc_lines else None, + capacity=capacity_m.group(0) if capacity_m else None, + bed=bed_m.group(0) if bed_m else None, + raw_text=chunk.strip(), + ) + ) + return rooms + + +def crawl_stay(page: Page, url: str) -> StayData: + page.goto(url, wait_until="domcontentloaded", timeout=60000) + try: + page.wait_for_selector("h1", timeout=15000) + except PWTimeoutError: + pass + + _scroll_through_page(page) + + data = StayData(url=url, stay_id=_extract_stay_id(url)) + data.name = _get_stay_name(page) + + rooms_text = _get_section_text(page, SECTION_IDS["rooms"]) + data.rooms = _parse_rooms_from_section_text(rooms_text) + + # 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고 + # (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다. + room_images = _get_room_images_by_name(page, SECTION_IDS["rooms"]) + if len(room_images) == len(data.rooms): + for room, (_, images) in zip(data.rooms, room_images): + room.images = images + else: + images_by_name = {name: images for name, images in room_images} + for room in data.rooms: + room.images = images_by_name.get(room.name, []) + + data.overview = _get_section_text(page, SECTION_IDS["overview"]) + data.photos = _get_section_images(page, SECTION_IDS["overview"]) + data.service = _get_section_text(page, SECTION_IDS["service"]) + data.policy = _get_section_text(page, SECTION_IDS["policy"]) + data.reservation = _get_section_text(page, SECTION_IDS["reservation"]) + + return data + + +def search_and_crawl(page: Page, address: str) -> StayData: + detail_url, picked_name = search_and_pick_first(page, address) + data = crawl_stay(page, detail_url) + data.search_address = address + data.picked_name = picked_name + return data + + +def _print_stay(data: StayData) -> None: + print(f"\n===== {data.name} ({data.url}) =====") + if data.search_address: + print(f"검색 주소: {data.search_address}") + if data.picked_name: + print(f"검색결과 선택: {data.picked_name}") + print("\n[객실 선택]") + for r in data.rooms: + print(f" - {r.name}") + if r.description: + print(f" 설명: {r.description}") + if r.capacity: + print(f" 인원: {r.capacity}") + if r.bed: + print(f" 침대: {r.bed}") + if r.images: + print(f" 사진: {len(r.images)}장 (예: {r.images[0]})") + if data.photos: + print(f"\n갤러리 사진: {len(data.photos)}장") + for label, key in [("숙소 소개", "overview"), ("시설/서비스", "service"), + ("이용 안내", "policy"), ("예약 공지", "reservation")]: + print(f"\n[{label}]") + text = getattr(data, key) + print(text if text else "(내용 없음)") + + +def main() -> None: + parser = argparse.ArgumentParser(description="야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러") + parser.add_argument( + "address", + nargs="?", + default="전북특별자치도 군산시 절골길 18", + help="검색할 주소 (예: '전북특별자치도 군산시 절골길 18')", + ) + parser.add_argument("-o", "--output", default="yanolja_result.json", help="결과 저장 파일명(JSON)") + parser.add_argument("--print", action="store_true", dest="do_print", help="결과를 터미널에도 보기 좋게 출력") + parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)") + args = parser.parse_args() + + print(f"[검색 시작] 주소: {args.address}", file=sys.stderr) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=not args.headful) + context = browser.new_context( + locale="ko-KR", + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + ), + ) + page = context.new_page() + + try: + data = search_and_crawl(page, args.address) + except Exception as e: + browser.close() + print(f"실패: {e}", file=sys.stderr) + sys.exit(1) + + browser.close() + + print(f" -> 완료: {data.name} (객실 {len(data.rooms)}개)", file=sys.stderr) + + if args.do_print: + _print_stay(data) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(asdict(data), f, ensure_ascii=False, indent=2) + + print(f"\n완료: 결과를 {args.output} 에 저장했습니다.") + + +if __name__ == "__main__": + main() diff --git a/solution/backend/services/azure_static.py b/solution/backend/services/azure_static.py index 8fea4e7..b2a37b8 100644 --- a/solution/backend/services/azure_static.py +++ b/solution/backend/services/azure_static.py @@ -22,7 +22,7 @@ def is_configured() -> bool: def output_dir() -> Path: - return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/out/sites")) + return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out")) def _content_type(path: Path) -> str: @@ -104,7 +104,7 @@ def _upload_shared(container, root: Path, prefix: str) -> set[str]: for entry in sorted(root.iterdir()): # `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다. # (여기서 함께 올리면 한 명이 발행할 때마다 전체 사이트를 다시 올리게 된다.) - if entry.name.startswith(".") or entry.name == SITE_ROOT_DIR: + if entry.name.startswith(".") or entry.name in {SITE_ROOT_DIR, "versions"}: continue if entry.is_dir(): uploaded |= _upload_tree(container, root, entry.name, prefix) diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 89ced65..34ff08d 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -29,21 +29,31 @@ 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_service, + 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 +from common.job_errors import PermanentJobError _site_crud = SiteCRUD() _place_crud = PlaceCRUD() -# 렌더러가 이 버전을 굽고 보고서를 돌려줄 때까지 기다리는 시간. -# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 대기 초과"로 실패한다 — -# 렌더러는 payload 를 2초 주기로 보고, 사이트 하나 굽는 데 1초 남짓 걸린다. +# 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함). +# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을 +# 내려받는 발행은 몇 초가 더 걸린다(mirrorMedia, prerender.ts). RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180) -class BuildAborted(RuntimeError): +class BuildAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" @@ -64,10 +74,13 @@ async def ensure_site(place_id: str) -> "sites": return row -async def _load_links(place_id: str) -> list: +async def load_channel_links(place_id: str) -> list: """채널 링크(야놀자·네이버 플레이스·인스타…). - 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다. + 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다 — 재빌드(run_build) + 도 롤백(rollback_service.run_rollback)도 같은 함수를 쓴다. **스냅샷에는 안 싣는다** — + 롤백이 옛 스냅샷으로 다시 구워도 링크는 항상 지금 확정된 것을 보여줘야 한다(끊긴 링크를 + 옛 버전째 되살리면 안 된다). ★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다.""" err, rows = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), @@ -157,6 +170,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 +206,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): @@ -224,24 +253,25 @@ async def run_build(job: dict) -> dict: site.status = SiteStatus.PUBLISHED.value site.published_at = site.published_at or now - links = await _load_links(place_id) - payload_path = emit_payload(place, snapshot, site, version, links) + links = await load_channel_links(place_id) + payload_path = await emit_payload(place, snapshot, site, version, links, publish=want_publish) if not payload_path: return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다") result["payload_path"] = payload_path slug = site_payload.publish_slug(place, site) - # ---- 렌더 결과를 기다린다 ---- + # ---- 렌더러를 직접 돌린다 ---- # ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해 # 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다. - report = await render_report.wait_for(slug, version_no, RENDER_TIMEOUT_SEC) - if report is None: + # ★ payload=False(미발행)면 렌더러는 `out/versions///` 에만 굽고 공개 + # 주소(`out/s/`)는 건드리지 않는다 — emit_payload 에 실은 publish 플래그가 정한다. + try: + report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC) + except render_service.RenderFailed as ex: # ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면 # 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다. - return await _fail( - f"렌더 결과를 {RENDER_TIMEOUT_SEC}초 안에 받지 못했다 — 렌더러가 도는지 확인이 필요하다" - ) + return await _fail(str(ex)) mismatches = list(report.get("mismatches") or []) # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고, @@ -283,8 +313,9 @@ async def run_build(job: dict) -> dict: thumbnail_url = None if want_publish: try: + await render_service.activate_site(slug, version_no) azure_result = await azure_static.publish(slug) - except azure_static.AzurePublishError as ex: + except (azure_static.AzurePublishError, render_service.RenderFailed) as ex: return await _fail(str(ex), None, stamp) if azure_result: result["azure"] = azure_result @@ -358,4 +389,3 @@ async def run_build(job: dict) -> dict: version.build_status = BuildStatus.BUILT.value version.built_at = now return result - diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index da8ae51..8474819 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -2,6 +2,7 @@ 수집 결과는 사용자가 승인하기 전까지 사이트에 노출하지 않는다. """ +import re import uuid from common.database.db_session_manager import DB_SESSION_MNG @@ -14,15 +15,17 @@ from crud.fact_crud import FactCRUD from crud.place_crud import PlaceCRUD from common.category_schema import get_schema from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY +from services.collector import yanolja_adapter from services.external import naver_place_lookup, perplexity, tour_lookup from services.fact_service import FactService from router.v1.fact.protocol import Req_UpsertFact +from common.job_errors import PermanentJobError _place_crud = PlaceCRUD() _fact_crud = FactCRUD() -class CollectAborted(RuntimeError): +class CollectAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다.""" @@ -107,9 +110,6 @@ async def discover_official_site(place, place_id: str) -> str: 이건 네이버가 그 업소 레코드에 달아 둔 값이고, 동일 업소 판정(`pick_match`)을 통과했을 때만 쓴다. 그래서 `discover_naver_place` 와 같은 근거로 자동 확정한다. - ★ 수집 금지 호스트도 **등록은 한다.** 인스타그램·OTA 는 robots 가 크롤을 막지만 - (`static_html_adapter._DENY_HOSTS`), 발행본의 공식 채널·sameAs 로는 유효한 사실이다. - 크롤 대상이 되어도 어댑터가 손들면 그 링크만 건너뛴다. """ from services.external import naver as naver_client @@ -189,6 +189,53 @@ async def discover_tour_api(place, place_id: str) -> str: return "resolved" if added else "already" +def _same_business(place_name: str, picked_name: str) -> bool: + """상호 문자열이 같은 업소를 가리키는지 느슨하게 판정한다. + + ★ 야놀자는 상호로 정확히 질의하는 공식 API 가 없다 — 주소 검색 첫 결과가 진짜 + 이 업소인지 이 상호 비교로 한 번 더 확인한 뒤에만 자동 확정한다.""" + def norm(s: str) -> str: + return re.sub(r"[\s,.\-·]+", "", s or "").lower() + p, q = norm(place_name), norm(picked_name) + return bool(p) and bool(q) and (p in q or q in p) + + +async def discover_yanolja(place, place_id: str) -> str: + """주소로 야놀자(NOL) 상세페이지를 검색해 등록한다. 반환 규약은 discover_naver_place 와 같다. + + ★ 상호로 직접 질의하는 공식 API 가 없어 주소 검색 결과에 의존한다. 그래서 + `discover_naver_place`·`discover_tour_api` 처럼 "해석"이라 부르기엔 근거가 약하다 — + 검색 결과 상호가 place.name 과 겹치는지(`_same_business`) 확인했을 때만 자동 확정하고, + 아니면 등록하지 않는다(남의 가게가 섞이는 것을 막는다). + """ + address = place.road_address or place.address + if not address: + return "not_found" + + try: + found = await yanolja_adapter.search_by_address(address) + except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 + LOG.w(f"[collect] 야놀자 검색 실패(계속): {type(ex).__name__}: {ex}") + return "error" + + if not found: + LOG.i(f"[collect] 야놀자 검색결과 없음 (place={place_id})") + return "not_found" + + url, picked_name = found + if not _same_business(place.name, picked_name): + LOG.i(f"[collect] 야놀자 검색결과 상호 불일치 — 자동 등록 안 함 ({place.name!r} vs {picked_name!r})") + return "not_found" + + added = await _add_link(place_id, LinkChannel.YANOLJA, url, f"{place.name} 야놀자", SourceType.API) + await DB_SESSION_MNG.execute_lambda_claim( + place_channels.DBType(), + lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()), + ) + LOG.i(f"[collect] 야놀자 링크 {'등록·확정' if added else '확정'} — {url}") + return "resolved" if added else "already" + + async def discover_links(place, place_id: str, *, include_perplexity: bool = False) -> dict: """채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태). @@ -230,6 +277,16 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}") stat["official_site"] = "error" + # 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고). + if PlaceCategory(place.category) == PlaceCategory.LODGING: + try: + stat["yanolja"] = await discover_yanolja(place, place_id) + if stat["yanolja"] == "resolved": + stat["discovered"] += 1 + except Exception as ex: # noqa: BLE001 + LOG.w(f"[collect] 야놀자 조회 실패(계속): {type(ex).__name__}: {ex}") + stat["yanolja"] = "error" + # 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도 # 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다. if not include_perplexity: diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 3bccef9..433499e 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -168,6 +168,44 @@ class NaverPlaceAdapter: booking_url=booking_url, ) + async def fetch_summary(self, url: str) -> Optional[dict]: + """이름·주소·좌표·대표사진 요약 — area_contents(주변 맛집 카드) 적재용. + + ★ fetch()와 별개 계약이다. fetch()는 fact/media(사업장 자신의 사실)를 돌려주고, + 이건 "이 업체가 누구인가"만 필요한 호출자(주변 맛집 보강)를 위한 것이다. + ★ services/place_service.py 의 verify_place_by_url 이 이미 같은 필드 + (name/roadAddress/address/coordinate.x·y)를 같은 방식으로 읽는다 — 필드명은 거기서 확인됐다. + ★ 사진은 fetch()가 쓰는 _to_media()를 그대로 재사용한다 — 이미 받아온 state 에서 + 꺼낼 뿐이라 네트워크 호출이 추가로 들지 않는다. + """ + try: + place_id = await self._resolve_place_id(url) + state = await self._load_state(place_id) + except Exception as ex: + LOG.w(f"[naver_place] 요약 조회 실패: {ex}") + return None + + base = state.get(f"PlaceDetailBase:{place_id}") or next( + (v for k, v in state.items() if k.startswith("PlaceDetailBase")), None + ) + name = str((base or {}).get("name") or "").strip() + if not base or not name: + return None + + coord = base.get("coordinate") or {} + summary = {"place_id": place_id, "name": name} + address = str(base.get("roadAddress") or base.get("address") or "").strip() + if address: + summary["address"] = address + if coord.get("y"): + summary["latitude"] = str(coord["y"]) + if coord.get("x"): + summary["longitude"] = str(coord["x"]) + media = self._to_media(state) + if media: + summary["imageUrl"] = media[0].origin_url + return summary + # ---- 내부 ---------------------------------------------------------- @staticmethod diff --git a/solution/backend/services/collector/registry.py b/solution/backend/services/collector/registry.py index 897830b..a5f320c 100644 --- a/solution/backend/services/collector/registry.py +++ b/solution/backend/services/collector/registry.py @@ -8,6 +8,8 @@ static_html 사장님이 확정한 자기 홈페이지. JSON-LD·OpenGraph 만 읽고 robots.txt 를 따른다. docs/DECISIONS.md 1-1 이 보류했던 어댑터로, 2026-08-28 실측 결론 (docs/DATA_SOURCE_RESEARCH.md)에 따라 **사장님 확정 URL 한정**으로 등록했다. + yanolja 야놀자(NOL) 국내숙소 상세페이지. Next.js CSR 페이지라 Playwright 로 렌더링해 + 읽는다. 캡차 우회·IP 회전 등은 하지 않으며, 차단되면 그대로 실패로 돌린다. mock mock:// 전용. 네트워크 없이 파이프라인 전체를 돌리는 테스트용. 켜고 끄는 것은 환경변수 COLLECT_ADAPTERS 다(쉼표 구분). 코드를 고치지 않고 한 채널만 @@ -27,10 +29,11 @@ from services.collector.mock_adapter import MockAdapter from services.collector.naver_place_adapter import NaverPlaceAdapter from services.collector.static_html_adapter import StaticHtmlAdapter from services.collector.tour_api_adapter import TourApiAdapter +from services.collector.yanolja_adapter import YanoljaAdapter # 이 환경에서 켜둘 어댑터 id 집합. 등록돼 있어도 여기 없으면 AdapterDisabled 로 막힌다. ENABLED_ADAPTERS: frozenset = frozenset( - x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html").split(",") if x.strip() + x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html,yanolja").split(",") if x.strip() ) @@ -82,6 +85,7 @@ def _register_default() -> AdapterRegistry: # 앞에 두면 네이버 플레이스 URL 까지 이쪽으로 빨려 들어간다. registry.register(NaverPlaceAdapter()) registry.register(TourApiAdapter()) + registry.register(YanoljaAdapter()) registry.register(MockAdapter()) registry.register(StaticHtmlAdapter()) return registry diff --git a/solution/backend/services/collector/static_html_adapter.py b/solution/backend/services/collector/static_html_adapter.py index f14b20b..19d3b13 100644 --- a/solution/backend/services/collector/static_html_adapter.py +++ b/solution/backend/services/collector/static_html_adapter.py @@ -13,15 +13,23 @@ 그 전제가 깨지면(플랫폼 URL 이 흘러들어오면) 정당성도 같이 깨지므로, 아래 _DENY_HOSTS 로 **구조적으로** 막는다. 운영자가 실수로 넣어도 안 긁힌다. -★ 표본 근거 — 왜 이 어댑터가 숙박에서 특히 값이 큰가 - 네이버 지역검색 `link` 필드 충전율(업종별 25건 표본): 숙박 96% 중 자체 도메인 19건, - 음식점 64%, 카페 92% 중 인스타 17건. 숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다. +★ NOL(nol.yanolja.com) 은 이 어댑터가 아니라 전용 어댑터가 받는다(2026-09-14). + 범용 HTML 수집을 OTA 로 넓힌 것이 아니다 — 레지스트리가 yanolja 를 이 어댑터보다 + **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고, 나머지 야놀자·여기어때 주소는 + 여기 _DENY_HOSTS 에서 그대로 막힌다. + ★ 실측(2026-09-15): 이 deny 목록에서 두 호스트가 빠져 있던 동안 범용 HTML 수집이 + `www.yanolja.com` · `goodchoice.kr` 을 받았다. 전용 어댑터를 들이는 것과 + 범용 수집기를 그 플랫폼에 푸는 것은 다른 일이다. **금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)** 캡차 우회 · 봇 탐지 우회 · IP 회전. 여기에 하나 더 — **robots.txt 를 확인하고 그대로 따른다.** 사장님 홈페이지라도 예외 없다. 막히면 실패로 돌려주고 폴백 3단계로 간다(공식 API → 사장님 붙여넣기 → 최소 정보 생성). +★ 표본 근거 — 왜 이 어댑터가 숙박에서 특히 값이 큰가 + 네이버 지역검색 `link` 필드 충전율(업종별 25건 표본): 숙박 96% 중 자체 도메인 19건, + 음식점 64%, 카페 92% 중 인스타 17건. 숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다. + ★ 값을 만드는 원칙 구조화된 것(JSON-LD schema.org, OpenGraph)만 fact 로 올린다. 본문 텍스트에서 키워드를 주워 억지로 매핑하지 않는다 — 유일한 예외가 체크인·체크아웃 시각인데, @@ -61,14 +69,14 @@ HEADERS = { } # ★ 이 호스트들은 이 어댑터가 절대 건드리지 않는다. -# - 전용 어댑터가 따로 있거나(naver_place) +# - 전용 어댑터가 따로 있거나(naver_place · yanolja) # - 실측·판례로 수집 불가 결론이 난 곳이거나(야놀자·여기어때·카카오맵) # - 공식 OAuth 로만 가져와야 하는 곳(인스타그램)이다. # can_handle 에서 걸러 AdapterNotFound 로 떨어뜨린다. _DENY_HOSTS = ( "naver.com", "naver.me", # 플레이스·지도·블로그·예약 — robots Disallow: / "kakao.com", "daum.net", # 카카오맵 — robots Disallow, 내부 API 406 - "yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례 + "yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례. NOL 은 전용 어댑터가 먼저 받는다 "dailyhotel.com", "catchtable.co.kr", "airbnb.co.kr", "airbnb.com", "booking.com", "agoda.com", "expedia.co.kr", diff --git a/solution/backend/services/collector/yanolja_adapter.py b/solution/backend/services/collector/yanolja_adapter.py new file mode 100644 index 0000000..707aae4 --- /dev/null +++ b/solution/backend/services/collector/yanolja_adapter.py @@ -0,0 +1,328 @@ +"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다. + +★ nol.yanolja.com 은 Next.js CSR 페이지라 httpx(정적 HTML)로는 못 읽는다 — 그래서 + static_html_adapter 가 아니라 이 어댑터가 Playwright 로 실제 브라우저 렌더링을 거친다. + 이건 봇 탐지 우회가 아니라 JS 렌더링이 필요한 페이지를 읽는 통상적인 방법이다 — + 캡차 우회·IP 회전·지문 위장 같은 건 하지 않는다(registry.py 의 영구 금지 원칙 그대로 유지). + 차단(403·챌린지 등)을 만나면 그대로 실패로 돌려주고 재시도·우회하지 않는다. + +수집 원칙 + - 페이지에 보이는 값만 옮긴다. 가격은 수집하지 않는다(불안정하고 예약 시점에 따라 바뀐다). + - 이미지는 원본 URL 그대로만 남긴다(origin_url) — 재게시 여부는 발행 게이트가 판단한다. + - 객실(unit)별 값은 scope="unit" 로 담는다. 숙소소개·시설/서비스·이용안내·예약공지는 + 전부 크롤링하지만 fact 로 만들지 않는다 — 숙소소개(intro)·객실소개(room_intro)는 + allow_llm=True 필드라 그대로 넣으면 절대규칙 7을 어기고, 나머지 셋은 스키마의 + 특정 필드와 1:1로 안 맞는다. 넷 다 RawSource.text 로 보존한다. 시설/서비스·이용안내· + 예약공지는 확정 링크의 payload.links[].stayGuide로도 전달해 원문을 표시한다. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + +from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright + +from common.enums import LinkChannel +from common.logger import LOG +from services.collector.base import CollectedFact, CollectedMedia, RawSource + +SEARCH_URL = "https://nol.yanolja.com/" +DETAIL_URL_RE = re.compile(r"nol\.yanolja\.com/stay/domestic/\d+", re.IGNORECASE) + +# 검색결과 카드는
+RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]' + +SECTION_IDS = { + "rooms": "PLACE_SECTION", + "overview": "OVERVIEW_SECTION", + "service": "SERVICE_SECTION", + "policy": "POLICY_SECTION", + "reservation": "RESERVATION_SECTION", +} + +# 원문을 보존할 섹션들. 생성 근거와 확정된 NOL 안내 표시가 같은 수집 원문을 쓴다. +_TEXT_ONLY_SECTIONS = ( + ("overview", "숙소 소개"), + ("service", "시설/서비스"), + ("policy", "이용 안내"), + ("reservation", "예약 공지"), +) + +_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" +) + + +@dataclass +class _RoomInfo: + name: str + description: Optional[str] = None + capacity: Optional[str] = None + bed: Optional[str] = None + images: list[str] = field(default_factory=list) + + +def _extract_stay_id(url: str) -> Optional[str]: + m = re.search(r"/stay/domestic/(\d+)", url) + return m.group(1) if m else None + + +def _is_real_photo_url(src: str) -> bool: + return bool(src) and "static/images" not in src + + +def _parse_capacity(capacity: Optional[str]) -> tuple[Optional[str], Optional[str]]: + if not capacity: + return None, None + std_m = re.search(r"기준\s*(\d+)인", capacity) + max_m = re.search(r"최대\s*(\d+)인", capacity) + return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None) + + +def _parse_rooms_from_section_text(section_text: str) -> list[_RoomInfo]: + """PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다. + 가격·예약 버튼 이하는 무시한다. 사이트 구조가 바뀌면 이 정규식도 손봐야 한다.""" + rooms: list[_RoomInfo] = [] + chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text) + for chunk in chunks[1:]: + lines = [l.strip() for l in chunk.split("\n") if l.strip()] + if not lines: + continue + name = lines[0] + capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk) + bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk) + desc_lines = [] + for l in lines[1:]: + if l.startswith("기준") or "체크인" in l or l == "숙박": + break + desc_lines.append(l.strip("()")) + rooms.append( + _RoomInfo( + name=name, + description=" ".join(desc_lines) if desc_lines else None, + capacity=capacity_m.group(0) if capacity_m else None, + bed=bed_m.group(0) if bed_m else None, + ) + ) + return rooms + + +async def _scroll_through_page(page: Page) -> None: + prev_height = -1 + for _ in range(30): + await page.mouse.wheel(0, 1200) + await page.wait_for_timeout(250) + cur_height = await page.evaluate("document.body.scrollHeight") + if cur_height == prev_height: + break + prev_height = cur_height + await page.evaluate("window.scrollTo(0, 0)") + + +async def _get_section_text(page: Page, element_id: str) -> str: + try: + await page.evaluate( + "(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }", + element_id, + ) + await page.wait_for_timeout(400) + return await page.evaluate( + "(id) => { const el = document.getElementById(id); return el ? el.innerText : ''; }", + element_id, + ) or "" + except Exception: + return "" + + +async def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]: + """카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 + 쌓인 이미지가 그 h2 의 몫이다.""" + try: + await page.evaluate( + "(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }", + element_id, + ) + await page.wait_for_timeout(400) + raw = await page.evaluate( + """(id) => { + const el = document.getElementById(id); + if (!el) return []; + const nodes = el.querySelectorAll('img, h2'); + const result = []; + let buf = []; + for (const n of nodes) { + if (n.tagName === 'IMG') { + if (n.src) buf.push(n.src); + } else if (n.tagName === 'H2') { + result.push({name: n.textContent.trim(), images: [...new Set(buf)]}); + buf = []; + } + } + return result; + }""", + element_id, + ) or [] + except Exception: + return [] + return [(item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) for item in raw] + + +async def _get_section_images(page: Page, element_id: str) -> list[str]: + try: + await page.evaluate( + "(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }", + element_id, + ) + await page.wait_for_timeout(400) + srcs = await page.evaluate( + """(id) => { + const el = document.getElementById(id); + if (!el) return []; + return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))]; + }""", + element_id, + ) or [] + except Exception: + return [] + return [u for u in srcs if _is_real_photo_url(u)] + + +async def _get_stay_name(page: Page) -> Optional[str]: + try: + h1 = await page.query_selector("h1") + return (await h1.inner_text()).strip() if h1 else None + except Exception: + return None + + +async def _new_page(): + """호출마다 브라우저를 새로 띄우고 닫는다 — 다른 어댑터처럼 상태를 들고 있지 않는다.""" + pw = await async_playwright().start() + browser = await pw.chromium.launch(headless=True) + context = await browser.new_context(locale="ko-KR", user_agent=_UA) + page = await context.new_page() + return pw, browser, page + + +async def search_by_address(address: str, timeout_ms: int = 15000) -> Optional[tuple[str, str]]: + """주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다. 못 찾으면 None. + + discover 단계(주소만 아는 상태에서 링크를 찾는 쪽)가 쓴다. 카드를 클릭하지 않고 + href 를 직접 읽어 이동한다 — 클릭 시 새 탭이 뜨거나 배너에 가로채이는 문제를 피한다. + """ + pw, browser, page = await _new_page() + try: + await page.goto(SEARCH_URL, wait_until="domcontentloaded") + search_box = page.get_by_role("combobox", name="검색어 입력") + await search_box.click() + await search_box.fill(address) + await search_box.press("Enter") + + try: + first_card = page.locator(RESULT_CARD_SELECTOR).first + await first_card.wait_for(state="visible", timeout=timeout_ms) + except PWTimeoutError: + return None + + name = await first_card.get_attribute("aria-label") or await first_card.inner_text() + detail_url = await first_card.get_attribute("href") + if not detail_url: + return None + + await page.goto(detail_url, wait_until="domcontentloaded") + return page.url, name + except Exception as ex: + LOG.w(f"[yanolja] 주소 검색 실패(계속): {type(ex).__name__}: {ex}") + return None + finally: + await browser.close() + await pw.stop() + + +class YanoljaAdapter: + """야놀자(NOL) 국내숙소 상세페이지 → 객실 fact·사진.""" + + id = "yanolja" + + def can_handle(self, url: str) -> bool: + return bool(DETAIL_URL_RE.search((url or "").lower())) + + async def fetch(self, url: str) -> RawSource: + pw, browser, page = await _new_page() + try: + await page.goto(url, wait_until="domcontentloaded", timeout=60000) + try: + await page.wait_for_selector("h1", timeout=15000) + except PWTimeoutError: + # ★ 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다. + pass + + name = await _get_stay_name(page) + if not name: + return RawSource.failure(url, self.id, "상세페이지를 읽지 못했다(차단되었거나 존재하지 않는 페이지)", LinkChannel.YANOLJA) + + await _scroll_through_page(page) + + rooms_text = await _get_section_text(page, SECTION_IDS["rooms"]) + rooms = _parse_rooms_from_section_text(rooms_text) + + room_images = await _get_room_images_by_name(page, SECTION_IDS["rooms"]) + if len(room_images) == len(rooms): + for room, (_, images) in zip(rooms, room_images): + room.images = images + else: + images_by_name = dict(room_images) + for room in rooms: + room.images = images_by_name.get(room.name, []) + + gallery = await _get_section_images(page, SECTION_IDS["overview"]) + + # 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어 + # RawSource.text 로만 싣는다(생성 근거). intro 계열은 allow_llm=True 라 + # fact 로 만들면 안 된다(절대규칙 7, 2026-08-31 사고: TourAPI 가 원문을 그대로 + # intro 로 밀어넣어 LLM 소개문을 영영 못 보이게 만들었다). + section_texts: list[str] = [] + for section_key, label in _TEXT_ONLY_SECTIONS: + body = await _get_section_text(page, SECTION_IDS[section_key]) + if body.strip(): + section_texts.append(f"[{label}]\n{body.strip()}") + page_text = "\n\n".join(section_texts)[:20000] + except Exception as ex: + return RawSource.failure(url, self.id, f"{type(ex).__name__}: {ex}", LinkChannel.YANOLJA) + finally: + await browser.close() + await pw.stop() + + facts: list[CollectedFact] = [] + media: list[CollectedMedia] = [] + + for src in gallery: + media.append(CollectedMedia(origin_url=src, label="갤러리")) + + for room in rooms: + std_cap, max_cap = _parse_capacity(room.capacity) + facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name)) + if std_cap: + facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name)) + if max_cap: + facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name)) + if room.bed: + facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name)) + # ★ room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유). + for src in room.images: + media.append(CollectedMedia(origin_url=src, label="객실 사진", unit_name=room.name)) + + if not facts and not media: + return RawSource.failure(url, self.id, "객실·소개 정보를 찾지 못했다", LinkChannel.YANOLJA) + + LOG.i(f"[yanolja] {name} — 객실 {len(rooms)}개 · fact {len(facts)}건 · 사진 {len(media)}장") + return RawSource( + url=url, + adapter_id=self.id, + channel=LinkChannel.YANOLJA, + text=page_text, + facts=facts, + media=media, + ) diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 9421f0f..848a5cd 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -1,250 +1,39 @@ -"""소개문·FAQ 생성 — COPY 잡이 하는 일. - -★ LLM 은 사실을 만들지 않는다. 문장만 쓴다. - - 입력은 **확보된 fact(노출 가능한 것)만**. 미검증 값으로 문장을 쓰면 그 문장도 미검증이다. - - 생성물은 `ground_check` 를 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다). - - 소개문·FAQ 는 **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 — 2026-09-10 결정. - 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 는 저장조차 하지 않는다. - 확인된 사실로 쓴 문장을 한 번 더 승인받게 하면 같은 사실을 두 번 승인하는 셈이고, - 실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다 - (근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절). - - 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다. -""" -import uuid - -from common.category_schema import CategorySchemaError, get_schema -from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units -from common.enums import ( - PUBLISHABLE_FACT_STATUSES, - DBWRType, - ErrorType, - FactStatus, - PlaceCategory, - SourceType, -) -from common.logger import LOG -from common.models.gmodel import UserInfo -from common.utils.gtime import GTime -from config.server_configs import external_api_config -from crud.fact_crud import FactCRUD -from crud.faq_crud import FaqCRUD -from crud.place_crud import PlaceCRUD -from router.v1.fact.protocol import Req_UpsertFact -from services import place_research +"""COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text.""" +from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs from services.external import gemini_text -from services.fact_service import FactService +from services.job_progress import JobProgress -_fact_crud = FactCRUD() -_faq_crud = FaqCRUD() -_place_crud = PlaceCRUD() - - -class CopyAborted(RuntimeError): - """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" +COPY_STEPS = ("prepare", "generate", "save", "faq_fill") async def run_copy(job: dict) -> dict: - """COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}""" + progress = JobProgress(job, COPY_STEPS) payload = job["payload"] - place_id = payload["place_id"] - owner_user_id = payload["owner_user_id"] + async with progress.step("prepare"): + inputs = await prepare_copy(payload["place_id"], payload["owner_user_id"]) - if not gemini_text.is_configured(): - raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") + copy = None + note = None + if inputs.ungrounded or not gemini_text.is_configured(): + note = "근거로 쓸 확인된 fact 가 없다" if inputs.ungrounded else "GEMINI_API_KEY 미설정" + await progress.skip("generate", "no_facts" if inputs.ungrounded else "not_configured") + if inputs.catalog is None and not inputs.ungrounded: + raise CopyAborted(note) + else: + async with progress.step("generate"): + copy = await generate_copy(inputs) - err, place = await DB_SESSION_MNG.execute_lambda( - places.DBType(), - DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), - ) - if err != ErrorType.SUCCESS or place is None: - raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") + async with progress.step("save"): + result = await save_copy(inputs, copy) - try: - schema = get_schema(PlaceCategory(place.category)) - except (CategorySchemaError, ValueError) as ex: - raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex + if inputs.catalog is not None: + async with progress.step("faq_fill"): + result["faq_fill"] = await fill_faqs( + inputs.place.place_id, inputs.catalog, inputs.known_fact_keys, inputs.place.phone, + ) + else: + await progress.skip("faq_fill", "no_catalog") - # ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다. - pid = uuid.UUID(place_id) - f_err, fact_rows = await DB_SESSION_MNG.execute_lambda( - place_facts.DBType(), - DBWRType.DB_READ.value, - lambda s: _fact_crud.list_facts(s, pid, None, None, True, True), - ) - if f_err != ErrorType.SUCCESS: - raise CopyAborted(f"fact 조회 실패: {f_err.name}") - - grounded = [ - gemini_text.FactInput( - key=r.key, - label=(schema.get(r.key).label if schema.get(r.key) else r.key), - value=r.value, - unit=r.unit, - ) - for r in fact_rows - if r.unit_id is None and (r.value or "").strip() - ] - # ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다. - # - # 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다. - # '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면 - # ground_check 가 그 문장을 전부 반려해 소개문·FAQ 가 앙상해진다. - # - # 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다. - # 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31). - # 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다. - # ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10). - # 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로 - # 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서, - # 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로 - # 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다. - # ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다. - # 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는 - # 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`). - records: list[str] = [] - l_err, link_rows = await DB_SESSION_MNG.execute_lambda( - place_channels.DBType(), - DBWRType.DB_READ.value, - lambda s: _place_crud.list_links(s, pid, False), - ) - if l_err == ErrorType.SUCCESS: - for link in (link_rows or []): - raw = link.raw if isinstance(link.raw, dict) else {} - if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND: - continue - text = (raw.get("text") or "").strip() - if text: - # ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로 - # 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측). - records.append(text[:4000]) - # ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다. - grounded.append(gemini_text.FactInput( - key=f"source:{link.link_id}", label="수집 원문", value=text[:4000], - )) - - # 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다. - # - # ★ 근거 없음 판정보다 **먼저** 읽는다. - # 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에 - # 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도 - # "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다. - u_err, unit_rows = await DB_SESSION_MNG.execute_lambda( - place_units.DBType(), - DBWRType.DB_READ.value, - lambda s: _place_crud.list_units(s, pid), - ) - unit_summaries = [] - if u_err == ErrorType.SUCCESS: - by_unit: dict = {} - for r in fact_rows: - if r.unit_id and (r.value or "").strip(): - by_unit.setdefault(str(r.unit_id), {})[r.key] = r.value - unit_summaries = [ - { - "name": u.name, - "facts": by_unit.get(str(u.unit_id), {}), - # 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는 - # 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다. - "labels": { - key: { - "label": schema.get(key).label if schema.get(key) else key, - "unit": schema.get(key).unit if schema.get(key) else None, - } - for key in by_unit.get(str(u.unit_id), {}) - }, - } - for u in unit_rows - if by_unit.get(str(u.unit_id)) - ] - - if not grounded and not unit_summaries: - # 근거가 없으면 아무것도 쓰지 않는다 — 유료 호출조차 하지 않는다. - return {"place_id": place_id, "intro": False, "faqs": 0, "note": "근거로 쓸 확인된 fact 가 없다"} - - try: - copy = await gemini_text.generate_copy( - place.name, - PlaceCategory(place.category), - grounded, - unit_summaries=unit_summaries or None, - records=records or None, - model=external_api_config.gemini_text_model, - ) - except gemini_text.GeminiNotConfigured as ex: - raise CopyAborted(str(ex)) from ex - - now = GTime.UTC() - stat = { - "place_id": place_id, - "grounded_facts": len(grounded), - "intro": False, - "meta": False, - "faqs": 0, - # ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다. - "rejected": [list(r) for r in (copy.rejected or [])][:20], - } - - actor = UserInfo( - # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 - # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. - # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 - # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. - user_id=owner_user_id, - id="generator", - role=1, - ) - service = FactService(_fact_crud, _place_crud) - - # 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음), - # LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기). - for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)): - if not (text_value or "").strip(): - continue - if not (schema.get(key) and schema.get(key).allow_llm): - # 이 업종 스키마가 LLM 작성을 허용하지 않는 필드다. 조용히 건너뛴다. - continue - res = await service.upsert_fact( - actor, place_id, - Req_UpsertFact( - key=key, value=text_value.strip(), - source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}", - ), - ) - if res.result.success: - stat["intro" if key == "intro" else "meta"] = True - else: - stat["rejected"].append([key, res.result.desc]) - - # 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다. - await DB_SESSION_MNG.execute_lambda_claim( - place_faqs.DBType(), - lambda s: _faq_crud.expire_generated(s, pid, now), - ) - for order, faq in enumerate(copy.faqs or []): - if not faq.fact_keys: - # ★ 근거 없는 FAQ 는 저장하지 않는다. - stat["rejected"].append([faq.question, "근거 fact 없음"]) - continue - row = place_faqs( - place_id=pid, - question=faq.question, - answer=faq.answer, - source_fact_ids=list(faq.fact_keys), - generated_by=SourceType.LLM.value, - # ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거). - # 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다. - status=FactStatus.VERIFIED.value, - sort_order=order, - ) - run_err = await DB_SESSION_MNG.execute_lambda_run( - [place_faqs.DBType()], - [lambda s, r=row: _faq_crud.add_faq(s, r)], - ) - if run_err == ErrorType.SUCCESS: - stat["faqs"] += 1 - - LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · " - f"반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)") - return stat + if note: + result["note"] = note + return result diff --git a/solution/backend/services/copy_steps.py b/solution/backend/services/copy_steps.py new file mode 100644 index 0000000..993ec41 --- /dev/null +++ b/solution/backend/services/copy_steps.py @@ -0,0 +1,327 @@ +"""소개문·FAQ 생성 — COPY 잡이 하는 일. + +★ LLM 은 사실을 만들지 않는다. 문장만 쓴다. + - 입력은 **확보된 fact(노출 가능한 것)만**. 미검증 값으로 문장을 쓰면 그 문장도 미검증이다. + - 생성물은 `ground_check` 를 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다). + - 소개문·FAQ 는 **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 — 2026-09-10 결정. + 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 는 저장조차 하지 않는다. + 확인된 사실로 쓴 문장을 한 번 더 승인받게 하면 같은 사실을 두 번 승인하는 셈이고, + 실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다 + (근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절). + - 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다. + - FAQ 가 목표 수(20)에 모자라면 업종 카탈로그에서 겹치지 않는 공통 질문을 **문의 안내** 답으로 채운다 + (services/faq_fill · common/faq_catalog). 답에 값·가능 여부를 적지 않으므로 사실을 만들지 않는다. + ★ fact 가 0건이어도(또는 API 키가 없어도) 채운다 — 그때는 LLM 을 부르지 않고 채우기만 한다. +""" +import uuid +from dataclasses import dataclass + +from common.category_schema import CategorySchema, CategorySchemaError, get_schema +from common.database.db_session_manager import DB_SESSION_MNG +from common.faq_catalog import FaqCatalog, find_catalog +from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units +from common.enums import ( + DBWRType, + ErrorType, + FactStatus, + PlaceCategory, + SourceType, +) +from common.logger import LOG +from common.models.gmodel import UserInfo +from common.utils.gtime import GTime +from config.server_configs import external_api_config +from crud.fact_crud import FactCRUD +from crud.faq_crud import FaqCRUD +from crud.place_crud import PlaceCRUD +from router.v1.fact.protocol import Req_UpsertFact +from services import faq_fill, place_research +from services.external import gemini_text +from services.fact_service import FactService +from common.job_errors import PermanentJobError + +_fact_crud = FactCRUD() +_faq_crud = FaqCRUD() +_place_crud = PlaceCRUD() + + +class CopyAborted(PermanentJobError): + """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" + + +@dataclass +class CopyInputs: + place: places + schema: CategorySchema + grounded: list[gemini_text.FactInput] + records: list[str] + unit_summaries: list[dict] + catalog: FaqCatalog | None + known_fact_keys: set[str] + + @property + def ungrounded(self) -> bool: + return not self.grounded and not self.unit_summaries + + +async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: + + err, place = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), + ) + if err != ErrorType.SUCCESS or place is None: + raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") + + try: + schema = get_schema(PlaceCategory(place.category)) + except (CategorySchemaError, ValueError) as ex: + raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex + + # ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다. + pid = uuid.UUID(place_id) + f_err, fact_rows = await DB_SESSION_MNG.execute_lambda( + place_facts.DBType(), + DBWRType.DB_READ.value, + lambda s: _fact_crud.list_facts(s, pid, None, None, True, True), + ) + if f_err != ErrorType.SUCCESS: + raise CopyAborted(f"fact 조회 실패: {f_err.name}") + + grounded = [ + gemini_text.FactInput( + key=r.key, + label=(schema.get(r.key).label if schema.get(r.key) else r.key), + value=r.value, + unit=r.unit, + ) + for r in fact_rows + if r.unit_id is None and (r.value or "").strip() + ] + # ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다. + # + # 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다. + # '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면 + # ground_check 가 그 문장을 전부 반려해 소개문·FAQ 가 앙상해진다. + # + # 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다. + # 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31). + # 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다. + # ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10). + # 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로 + # 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서, + # 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로 + # 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다. + # ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다. + # 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는 + # 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`). + records: list[str] = [] + l_err, link_rows = await DB_SESSION_MNG.execute_lambda( + place_channels.DBType(), + DBWRType.DB_READ.value, + lambda s: _place_crud.list_links(s, pid, False), + ) + if l_err == ErrorType.SUCCESS: + for link in (link_rows or []): + raw = link.raw if isinstance(link.raw, dict) else {} + if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND: + continue + text = (raw.get("text") or "").strip() + if text: + # ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로 + # 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측). + records.append(text[:4000]) + # ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다. + grounded.append(gemini_text.FactInput( + key=f"source:{link.link_id}", label="수집 원문", value=text[:4000], + )) + + # 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다. + # + # ★ 근거 없음 판정보다 **먼저** 읽는다. + # 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에 + # 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도 + # "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다. + u_err, unit_rows = await DB_SESSION_MNG.execute_lambda( + place_units.DBType(), + DBWRType.DB_READ.value, + lambda s: _place_crud.list_units(s, pid), + ) + unit_summaries = [] + if u_err == ErrorType.SUCCESS: + by_unit: dict = {} + for r in fact_rows: + if r.unit_id and (r.value or "").strip(): + by_unit.setdefault(str(r.unit_id), {})[r.key] = r.value + unit_summaries = [ + { + "name": u.name, + "facts": by_unit.get(str(u.unit_id), {}), + # 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는 + # 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다. + "labels": { + key: { + "label": schema.get(key).label if schema.get(key) else key, + "unit": schema.get(key).unit if schema.get(key) else None, + } + for key in by_unit.get(str(u.unit_id), {}) + }, + } + for u in unit_rows + if by_unit.get(str(u.unit_id)) + ] + + # FAQ 채우기에 쓸 업종 카탈로그. 없으면(카페·음식점·호텔) 채우지 않는다. + catalog = find_catalog(place.category, place.external_category) + # 사업장·객실 fact 를 가리지 않는다 — "기준 인원" 은 객실 fact 로 답한다. + known_fact_keys = {r.key for r in fact_rows if (r.value or "").strip()} + + return CopyInputs(place, schema, grounded, records, unit_summaries, catalog, known_fact_keys) + + +async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy: + try: + return await gemini_text.generate_copy( + inputs.place.name, + PlaceCategory(inputs.place.category), + inputs.grounded, + unit_summaries=inputs.unit_summaries or None, + records=inputs.records or None, + suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None, + max_faqs=faq_fill.FAQ_TARGET, + model=external_api_config.gemini_text_model, + ) + except gemini_text.GeminiNotConfigured as ex: + raise CopyAborted(str(ex)) from ex + + +async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) -> dict: + pid = inputs.place.place_id + place_id = str(pid) + schema = inputs.schema + now = GTime.UTC() + stat = { + "place_id": place_id, + "grounded_facts": len(inputs.grounded), + "intro": False, + "meta": False, + "faqs": 0, + "faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수 + # ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다. + "rejected": [list(r) for r in (copy.rejected or [])][:20] if copy else [], + } + + if copy is None: + # 키만 없는 경우에는 기존 생성물을 보존한다. + if inputs.ungrounded and inputs.catalog is not None: + await DB_SESSION_MNG.execute_lambda_claim( + place_faqs.DBType(), lambda s: _faq_crud.expire_generated(s, pid, now), + ) + return stat + + actor = UserInfo( + # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 + # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. + # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 + # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + user_id=str(inputs.place.owner_user_id), + id="generator", + role=1, + ) + service = FactService(_fact_crud, _place_crud) + + # 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음), + # LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기). + for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)): + if not (text_value or "").strip(): + continue + if not (schema.get(key) and schema.get(key).allow_llm): + # 이 업종 스키마가 LLM 작성을 허용하지 않는 필드다. 조용히 건너뛴다. + continue + res = await service.upsert_fact( + actor, place_id, + Req_UpsertFact( + key=key, value=text_value.strip(), + source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}", + ), + ) + if res.result.success: + stat["intro" if key == "intro" else "meta"] = True + else: + stat["rejected"].append([key, res.result.desc]) + + # 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다. + await DB_SESSION_MNG.execute_lambda_claim( + place_faqs.DBType(), + lambda s: _faq_crud.expire_generated(s, pid, now), + ) + for order, faq in enumerate(copy.faqs or []): + if not faq.fact_keys: + # ★ 근거 없는 FAQ 는 저장하지 않는다. + stat["rejected"].append([faq.question, "근거 fact 없음"]) + continue + row = place_faqs( + place_id=pid, + question=faq.question, + answer=faq.answer, + source_fact_ids=list(faq.fact_keys), + generated_by=SourceType.LLM.value, + # ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거). + # 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다. + status=FactStatus.VERIFIED.value, + sort_order=order, + ) + run_err = await DB_SESSION_MNG.execute_lambda_run( + [place_faqs.DBType()], + [lambda s, r=row: _faq_crud.add_faq(s, r)], + ) + if run_err == ErrorType.SUCCESS: + stat["faqs"] += 1 + + LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · " + f"반려 {len(stat['rejected'])}건 (근거 fact {len(inputs.grounded)}개)") + return stat + + +async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int: + """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다. 넣은 건수를 돌려준다. + + ★ 기존 FAQ 는 **노출 중인 것 전부**로 센다 — 방금 넣은 생성분만이 아니라 재생성이 남긴 + 사장님 입력·정정분까지. 그래야 사장님이 이미 답한 주제에 문의 안내가 겹쳐 붙지 않는다. + ★ 바로 노출값(VERIFIED)으로 넣는다. 답이 주장을 하지 않아 확인할 대상이 없다 — + 대신 JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다(shared selectAnsweredFaqs).""" + l_err, rows = await DB_SESSION_MNG.execute_lambda( + place_faqs.DBType(), + DBWRType.DB_READ.value, + lambda s: _faq_crud.list_faqs(s, pid, True), + ) + if l_err != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}") + return 0 + + picks = faq_fill.pick_fill_faqs( + catalog, + [faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows], + known_fact_keys, + phone, + ) + next_order = max((r.sort_order for r in rows), default=-1) + 1 + added = 0 + for offset, pick in enumerate(picks): + row = place_faqs( + place_id=pid, + question=pick.question, + answer=pick.answer, + source_fact_ids=None, + generated_by=SourceType.TEMPLATE.value, + status=FactStatus.VERIFIED.value, + sort_order=next_order + offset, + ) + run_err = await DB_SESSION_MNG.execute_lambda_run( + [place_faqs.DBType()], + [lambda s, r=row: _faq_crud.add_faq(s, r)], + ) + if run_err == ErrorType.SUCCESS: + added += 1 + return added diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 0208b71..e55e06e 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -10,6 +10,7 @@ 한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도 어디를 봐야 할지가 파일 안에서 갈리지 않았다. """ +import hashlib import json from dataclasses import dataclass, field from typing import Optional @@ -97,6 +98,7 @@ async def generate_copy( *, unit_summaries: Optional[list[dict]] = None, records: Optional[list[str]] = None, + suggested_questions: Optional[list[str]] = None, max_faqs: int = 8, model: str = DEFAULT_TEXT_MODEL, max_retries: int = 2, @@ -125,7 +127,7 @@ async def generate_copy( body = { "contents": [{"role": "user", "parts": [{ - "text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records) + "text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions) }]}], "generationConfig": { "responseMimeType": "application/json", @@ -196,6 +198,71 @@ async def generate_copy( return result +# ── 요약(summarize_text) ────────────────────────────────────────────────── +# ★ generate_copy 와 다르다: 여기서 압축하는 문장은 **이미 승인된 값**이다(fact 로 저장된 intro· +# room_intro). 새 사실을 만드는 게 아니라 같은 내용을 짧게 쓰는 것뿐이라 ground_check 를 다시 +# 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다. +_SUMMARY_CACHE: dict[str, str] = {} +_SUMMARY_CACHE_MAX = 500 +_SUMMARY_PROMPT = ( + "다음 숙소 소개에서 핵심 특징 1~2개만 골라 한국어 한 문장, 공백 포함 60~80자로 요약해줘. " + "원문에 없는 사실이나 과장 표현을 추가하지 말고, 선택한 사실의 조건과 부정 표현을 유지해. " + "반복되는 상호명, 인사말, 홍보 수식어는 생략해. " + "요약문만 출력하고 다른 말은 붙이지 마.\n\n" +) + + +async def summarize_text( + text: str, + *, + model: str = DEFAULT_TEXT_MODEL, + max_retries: int = 2, + client: Optional[httpx.AsyncClient] = None, +) -> Optional[str]: + """캔버스 미리보기용 축약문. 실패해도 예외를 올리지 않는다 — 호출측은 None 이면 원문을 쓴다. + + ★ DB 에 남기지 않는다. 같은 원문은 프로세스 메모리 캐시(sha256 키)로 재호출을 막는다 + (서버 재시작하면 비워진다 — 요구사항: "DB 저장은 생략하고 프론트 응답에만 실어준다"). + """ + stripped = text.strip() + if not stripped: + return None + if not is_configured(): + return None + + # 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다. + cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest() + cached = _SUMMARY_CACHE.get(cache_key) + if cached is not None: + return cached + + body = { + "contents": [{"role": "user", "parts": [{ + "text": _SUMMARY_PROMPT + stripped, + }]}], + "generationConfig": {"temperature": 0.2}, + } + + owns_client = client is None + client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) + try: + payload = await call(client, model, body, max_retries) + summary = extract_text(payload).strip() + except GeminiError as ex: + LOG.w(f"[gemini-text] 요약 실패: {ex}") + return None + finally: + if owns_client: + await client.aclose() + + if not summary: + return None + if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX: + _SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다. + _SUMMARY_CACHE[cache_key] = summary + return summary + + @dataclass class GeneratedSong: """가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다.""" diff --git a/solution/backend/services/external/restaurant_discovery.py b/solution/backend/services/external/restaurant_discovery.py new file mode 100644 index 0000000..30736dc --- /dev/null +++ b/solution/backend/services/external/restaurant_discovery.py @@ -0,0 +1,57 @@ +"""지역명으로 맛집 후보 이름을 찾는다. + +★ services/external/perplexity.py(채널 발견: 단일 업소 → URL)와는 다른 용도다 — + 여기는 "이 지역에 뭐가 있나"를 묻는 지역 목록 검색이다. 원칙은 같다: + Perplexity 응답을 사실로 쓰지 않는다. 이름만 후보로 받고, 실제 값은 이후 + 네이버 크롤링(NaverPlaceAdapter)이 확정한다. +""" +import json + +from common.logger import LOG +from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call +from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt + +MAX_RESULTS = 10 + + +def _parse_names(payload: dict) -> list[str]: + choices = payload.get("choices") or [] + if not choices or not isinstance(choices[0], dict): + return [] + content = ((choices[0].get("message") or {}).get("content")) or "" + try: + doc = json.loads(content) + except (json.JSONDecodeError, TypeError): + LOG.w("[restaurant_discovery] 구조화 출력 파싱 실패") + return [] + names = doc.get("restaurants") + if not isinstance(names, list): + return [] + return [str(n).strip() for n in names if str(n or "").strip()][:MAX_RESULTS] + + +async def search_region_restaurants( + region_label: str, *, model: str = DEFAULT_MODEL, client=None, +) -> list[str]: + """지역명으로 맛집 상위 10곳의 이름만 받는다. + + 실패(미설정·타임아웃·5xx)하면 빈 목록 — 호출측이 TourAPI 결과만으로 계속 진행한다. + """ + if not (region_label or "").strip(): + return [] + body = { + "model": model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_prompt(region_label)}, + ], + "max_tokens": DEFAULT_MAX_TOKENS, + "temperature": 0, + "response_format": RESPONSE_SCHEMA, + } + try: + payload = await call(body, client=client) + except PerplexityError as ex: + LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}") + return [] + return _parse_names(payload) 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/fact_service.py b/solution/backend/services/fact_service.py index c18e6ed..c1ebd31 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -33,6 +33,7 @@ from router.v1.fact.protocol import ( Res_FactList, ) from services.external.gemini_extract import extract_facts +from services.intro_summary import summarize_intro from services.llm.gemini import GeminiError, GeminiNotConfigured # 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 — @@ -40,7 +41,7 @@ from services.llm.gemini import GeminiError, GeminiNotConfigured # '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다. OWNER_PASTE_SOURCE = "owner:paste" -# 자동 수집 출처 — 노출값을 직접 바꾸지 못하고 후보로만 들어간다. +# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다. _AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM) @@ -48,10 +49,9 @@ class FactService: """fact 기록 + 검증 상태 전이. ── 세 갈래 프로세스 ──────────────────────────────────────────────── - 생성 : 자동 수집 → 후보(UNVERIFIED) → 사람이 승인 → 노출값(VERIFIED) → 사이트 빌드 - 업데이트: 재수집 → 값이 같으면 검증 유지(REFRESHED) - 값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER)로 적재 - → 사람이 승인해야 노출값이 교체된다 + 생성 : 크롤링 → 노출값(VERIFIED), API → 후보 → 사람이 승인 + 업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다. + API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER)로 적재한다. 수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다 문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값 (사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석) @@ -63,8 +63,8 @@ class FactService: 1. key 는 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY) 2. owner 가 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED) 3. LLM 은 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7) - 4. 자동 수집은 노출값을 직접 못 바꾼다 — 후보로만 (절대규칙 1·6). - ★ 예외는 LLM 문장 하나뿐이고, 그것도 CORRECTED 는 못 덮는다 + 4. 크롤링은 승인 없이 노출한다. 사람 입력·정정본은 덮지 않는다. + LLM 문장도 즉시 노출하되 CORRECTED는 보존한다. 5. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만 """ @@ -133,12 +133,21 @@ class FactService: res.result.SetResult(list_err) return res res.facts = [FactData.model_validate(r) for r in rows] + await self._attach_summaries(res.facts) # ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다. res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES) # 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지. res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER) return res + async def _attach_summaries(self, facts: list) -> None: + """intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다. + + ★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다. + 원문이 짧으면 API 를 부르지 않는다(summarize_intro).""" + for f in facts: + f.summary = await summarize_intro(f.key, f.value) + # ---- 기록 ---- async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts: """사장님이 붙여넣은 원문 → fact 후보. @@ -237,7 +246,8 @@ class FactService: """fact 를 기록한다. 출처에 따라 경로가 갈린다. 사람(owner) → 노출값을 직접 교체한다(수정 프로세스) - 자동 수집 → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재(업데이트 프로세스) + 크롤링 → 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존 + API → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재 """ res = Res_Fact() err_type, place = await self._load_place(user_info, place_id) @@ -266,6 +276,12 @@ class FactService: res.result.SetResult(ErrorType.FACT_INVALID_KEY) return res + # 규칙 4 — TEMPLATE 은 FAQ 문의 안내 전용 출처다(services/faq_fill). fact 에는 쓸 수 없다. + # OWNER 도 자동 수집도 아니라서, 막지 않으면 아래 분기에서 사람 입력처럼 바로 노출값이 된다. + if req.source_type == SourceType.TEMPLATE: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + pid = uuid.UUID(place_id) pub_err, published = await DB_SESSION_MNG.execute_lambda( place_facts.DBType(), @@ -279,6 +295,17 @@ class FactService: now = GTime.UTC() same_value = published is not None and (published.value or "") == (req.value or "") + # 2026-09-14: 크롤링 원값을 바로 표시한다. 사람 입력의 출처까지 바뀌지 않게 + # 동일값 갱신보다 먼저 보호한다. + if req.source_type == SourceType.CRAWL and published is not None and ( + published.source_type == SourceType.OWNER.value + or FactStatus(published.status) in LOCKED_FACT_STATUSES + ): + if same_value: + res.outcome = FactWriteOutcome.REFRESHED + return await self._reload(res, pid, published.fact_id) + return await self._write_candidate(res, pid, req, published, now, spec) + # ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ── if same_value: run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim( @@ -305,7 +332,7 @@ class FactService: # ★ 단 사장님이 고친 문장(CORRECTED)은 덮지 않는다. 지금까지 이 잠금은 "자동 출처는 # 노출값에 손을 못 댄다" 는 경로 자체가 지켜 줬는데(_write_candidate), LLM 만 경로를 # 바꾸면 그 보호가 사라진다 — 잠금은 여기서 명시적으로 다시 건다(절대규칙 6). - if req.source_type == SourceType.LLM: + if req.source_type in (SourceType.LLM, SourceType.CRAWL): if published is not None and FactStatus(published.status) in LOCKED_FACT_STATUSES: return await self._write_candidate(res, pid, req, published, now, spec) return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info) @@ -367,9 +394,9 @@ class FactService: return res async def _replace_published(self, res, place_id, pid, req, published, now, spec, user_info): - """사람이 직접 입력 — 노출값을 즉시 교체한다(수정 프로세스). + """직접 입력·크롤링·허용된 LLM 문장을 노출값으로 교체한다. - 사람이 넣은 값은 그 사람이 곧 출처이자 책임 주체라 별도 확인 단계를 두지 않는다. + 크롤링 자동 노출은 verified_by를 비워 사람이 승인한 이력과 구별한다. 기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다.""" fact = place_facts( place_id=pid, @@ -380,7 +407,7 @@ class FactService: source_type=req.source_type.value, source_url=(req.source_url or None), collected_at=now, - verified_by=uuid.UUID(user_info.user_id), + verified_by=None if req.source_type == SourceType.CRAWL else uuid.UUID(user_info.user_id), verified_at=now, status=FactStatus.VERIFIED.value, expires_at=req.expires_at, @@ -390,6 +417,8 @@ class FactService: [ lambda s: self._expire_then_ok(s, pid, req, now), lambda s: self.crud.add_fact(s, fact), + *([lambda s: self._reject_others_ok(s, pid, fact, now, fact.fact_id)] + if req.source_type == SourceType.CRAWL else []), ], ) if run_err != ErrorType.SUCCESS: diff --git a/solution/backend/services/faq_fill.py b/solution/backend/services/faq_fill.py new file mode 100644 index 0000000..b6ca2ad --- /dev/null +++ b/solution/backend/services/faq_fill.py @@ -0,0 +1,98 @@ +"""FAQ 목표 수 채우기 — 생성된 FAQ 가 모자라면 카탈로그에서 겹치지 않는 공통 질문을 고른다. + +순수 함수만 둔다(DB·네트워크 없음). 무엇이 왜 골렸는지를 이 파일만 읽고 답할 수 있어야 한다. + +고르는 규칙 (카탈로그 순서 = 우선순위) + 1. 답할 fact 가 있는 질문은 고르지 않는다 — 그건 LLM 이 fact 로 답할 자리다. + "주차할 수 있나요?" 에 parking=true 가 있는데 "문의 부탁드립니다" 가 붙으면 아는 것을 숨긴 셈이다. + 2. 기존 FAQ 가 이미 다룬 주제는 고르지 않는다. 기존 FAQ 에는 LLM 생성분 · 사장님 입력 · 정정분이 모두 든다. + - 근거 fact key 가 겹치면 같은 주제다. LLM 은 "주차 및 와이파이" 처럼 두 주제를 한 문항에 묶고 + 근거에 [parking, wifi] 를 적는다 — 낱말 대조만으로는 둘 중 하나를 놓친다. + - 질문에 카탈로그 키워드가 들어 있으면 같은 주제다(사장님 입력은 근거 key 가 없다). + 3. 공통 답은 문의 안내뿐이다. 값·가능 여부를 적지 않는다(common/faq_catalog 머리주석). +""" +import re +from dataclasses import dataclass +from typing import Iterable, Optional + +from common.faq_catalog import FaqCatalog + +# ★ 사이트에 싣는 FAQ 목표 수. 생성 상한(max_faqs)도 이 값을 쓴다. +FAQ_TARGET = 20 + + +@dataclass(frozen=True) +class ExistingFaq: + question: str + source_fact_ids: Optional[list] = None + + +@dataclass(frozen=True) +class FillFaq: + catalog_id: str + question: str + answer: str + + +def _compact(text: str) -> str: + return re.sub(r"\s+", "", text or "").lower() + + +def _base_key(key: str) -> str: + """객실 근거는 "A동:max_capacity" 로 적힌다 — 주제 비교에는 key 만 쓴다.""" + return str(key).rsplit(":", 1)[-1] + + +def _with_topic_particle(topic: str) -> str: + """주제 뒤에 은/는 을 붙인다. 받침이 있으면 '은'.""" + last = topic.strip()[-1:] + if "가" <= last <= "힣": + return topic + ("은" if (ord(last) - ord("가")) % 28 else "는") + return topic + "은(는)" + + +def fallback_answer(catalog: FaqCatalog, index: int, topic: str, phone: Optional[str]) -> str: + """문의 안내 문구. 문구를 번갈아 써서 스무 줄이 전부 같은 문장이 되지 않게 한다.""" + phone = (phone or "").strip() + templates = catalog.fallback_with_contact if phone else catalog.fallback_without_contact + template = templates[index % len(templates)] + return template.format(topic=_with_topic_particle(topic), contact=f"전화({phone})") + + +def pick_fill_faqs( + catalog: FaqCatalog, + existing: Iterable[ExistingFaq], + fact_keys: Iterable[str], + phone: Optional[str] = None, + target: int = FAQ_TARGET, +) -> list[FillFaq]: + """목표 수까지 모자란 만큼 카탈로그 질문을 고른다. 모자라지 않으면 빈 목록.""" + existing = list(existing) + need = target - len(existing) + if need <= 0: + return [] + + asked = [_compact(faq.question) for faq in existing] + covered_keys = {_base_key(k) for faq in existing for k in (faq.source_fact_ids or [])} + known_keys = {_base_key(k) for k in fact_keys} + + picked: list[FillFaq] = [] + for item in catalog.items: + if len(picked) >= need: + break + if known_keys.intersection(item.fact_keys): + continue # 규칙 1 + if covered_keys.intersection(item.fact_keys): + continue # 규칙 2 — 근거 key + if any(keyword in question for keyword in item.keywords for question in asked): + continue # 규칙 2 — 질문 낱말 + answer = fallback_answer(catalog, len(picked), item.topic, phone) + picked.append(FillFaq(item.id, item.question, answer)) + return picked + + +def suggested_questions(catalog: FaqCatalog, fact_keys: Iterable[str]) -> list[str]: + """fact 로 답할 수 있는 카탈로그 질문 — 프롬프트에 실어 LLM 이 이 질문들부터 쓰게 한다. + 채우기(규칙 1)가 이 질문들을 건너뛰므로, LLM 이 안 쓰면 그 주제는 비게 된다.""" + known = {_base_key(k) for k in fact_keys} + return [item.question for item in catalog.items if known.intersection(item.fact_keys)] diff --git a/solution/backend/services/grounding/itinerary.py b/solution/backend/services/grounding/itinerary.py index 60c3d3a..ed1b2c8 100644 --- a/solution/backend/services/grounding/itinerary.py +++ b/solution/backend/services/grounding/itinerary.py @@ -178,9 +178,17 @@ def _stop_names(course: dict) -> list[str]: return out +def stop_signature(course: dict) -> frozenset[str]: + """코스의 정거장 집합 — 중복 판정 기준. 호출 하나를 넘어 여러 번의 재시도에 걸쳐 + 같은 코스를 다시 채택하지 않으려면, 이전에 채택한 코스의 signature 를 다음 호출의 + `already_seen` 에 실어 보내야 한다(`itinerary_llm_service` 가 그렇게 누적한다).""" + return frozenset(_stop_names(course)) + + def parse_courses( payload: dict, duration: str, place_name: str, place_lat: float | None = None, place_lng: float | None = None, + already_seen: set[frozenset[str]] | None = None, ) -> tuple[list[dict], list[str]]: """(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. @@ -189,6 +197,10 @@ def parse_courses( ★ place_name·place_lat·place_lng 는 업소를 정거장으로 넣을 때 쓴다(`_apply_schedule`) — 모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 전) 이름만 들어가고 핀은 안 찍힌다. + + ★ already_seen 은 **이전 호출**(재시도)에서 이미 채택한 코스들의 `stop_signature` 다. + 10개를 채우려고 같은 프롬프트로 다시 부르면 모델이 앞서 낸 것과 겹치는 코스를 또 + 낼 수 있다 — 이 payload 안에서만 중복을 보면 그걸 새로 채택해 버린다. """ text = _FENCE_RE.sub("", _payload_text(payload)).strip() if not text: @@ -209,7 +221,7 @@ def parse_courses( fallback = _first_source(payload) out: list[dict] = [] dropped: list[str] = [] - seen_stop_sets: list[frozenset[str]] = [] + seen_stop_sets: list[frozenset[str]] = list(already_seen or ()) for raw in raw_items: if not isinstance(raw, dict): 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/indexnow.py b/solution/backend/services/indexnow.py index 69d7731..74ba052 100644 --- a/solution/backend/services/indexnow.py +++ b/solution/backend/services/indexnow.py @@ -44,7 +44,7 @@ def is_configured() -> bool: def output_dir() -> Path: - return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/out/sites")) + return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out")) def site_urls(slug: str) -> list[str]: diff --git a/solution/backend/services/intro_summary.py b/solution/backend/services/intro_summary.py new file mode 100644 index 0000000..eb24caf --- /dev/null +++ b/solution/backend/services/intro_summary.py @@ -0,0 +1,10 @@ +"""정보 조회와 발행 payload가 같은 조건으로 소개문 요약을 사용한다.""" +from services.external import gemini_text + + +async def summarize_intro(key: str, value: str | None) -> str | None: + # 짧은 소개까지 유료 호출하지 않는다. 원문은 DB에 그대로 두고 응답만 보강한다. + text = (value or "").strip() + if key not in {"intro", "room_intro"} or len(text) <= 80: + return None + return await gemini_text.summarize_text(text) diff --git a/solution/backend/services/itinerary_llm_service.py b/solution/backend/services/itinerary_llm_service.py index 73b4196..1c7f328 100644 --- a/solution/backend/services/itinerary_llm_service.py +++ b/solution/backend/services/itinerary_llm_service.py @@ -33,6 +33,14 @@ from services.prompts import itinerary as prompts # ★ 이야기(240초)와 같은 값이다. 2박 3일이 48초까지 갔고 변동이 크다(실측 2026-09-11). _TIMEOUT = httpx.Timeout(240.0, connect=10.0) +# ★ 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). 프롬프트만으로는 +# 보장이 안 돼(모델이 5개로 회귀할 때가 있다, 위 파일 주석 참고) 여기서 재시도로 채운다. +TARGET_COURSES = 10 + +# ★ 사장님 지시(2026-09-14): 2회로 제한한다. 늘릴수록 10개를 채울 확률은 오르지만 건당 +# 20~50초가 배로 늘어난다 — 못 채우면 채운 만큼만 저장하고 note 로 남긴다(아래 _generate_one). +MAX_ATTEMPTS = 2 + _CRUD = PlaceItineraryCRUD() @@ -98,8 +106,12 @@ async def _generate_one( client: httpx.AsyncClient, place_name: str, region: str, duration: str, place_lat: float | None = None, place_lng: float | None = None, ): - """기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다. + """기간 하나. 실패는 예외로 올리지 않고 (코스 목록, 이유) 로 돌려준다. + ★ TARGET_COURSES 개를 채울 때까지 같은 프롬프트로 최대 MAX_ATTEMPTS 번 다시 부른다 — + 한 번의 호출로 10개가 안정적으로 안 나온다(`prompts.itinerary` 실측 주석). 이전 시도에서 + 이미 채택한 코스는 `already_seen` 으로 다음 시도에 넘겨, 재시도가 같은 코스를 또 + 채택해 개수만 부풀리지 않게 한다(`grounding.stop_signature`). ★ place_lat·place_lng 는 업소를 정거장(출발·복귀)으로 넣을 때 쓴다(`grounding.parse_courses`) — 모델에게 묻지 않는다. 없으면 이름만 들어가고 지도 핀은 안 찍힌다.""" body = { @@ -110,17 +122,37 @@ async def _generate_one( ], "max_tokens": prompts.MAX_TOKENS, } - try: - payload = await perplexity.call(body, client=client) - except perplexity.PerplexityNotConfigured: - return [], ["PERPLEXITY_API_KEY 미설정"] - except perplexity.PerplexityError as ex: - LOG.w(f"[itinerary] {duration} 호출 실패 place={place_name}: {ex}") - return [], [f"호출 실패: {ex}"] - courses, dropped = grounding.parse_courses(payload, duration, place_name, place_lat, place_lng) - LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림") - return courses, dropped + courses: list[dict] = [] + seen: set[frozenset[str]] = set() + notes: list[str] = [] + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + payload = await perplexity.call(body, client=client) + except perplexity.PerplexityNotConfigured: + return [], ["PERPLEXITY_API_KEY 미설정"] + except perplexity.PerplexityError as ex: + LOG.w(f"[itinerary] {duration} {attempt}차 호출 실패 place={place_name}: {ex}") + notes.append(f"{attempt}차 호출 실패: {ex}") + break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다 + + new_courses, dropped = grounding.parse_courses( + payload, duration, place_name, place_lat, place_lng, already_seen=seen) + notes += dropped + for course in new_courses: + if len(courses) >= TARGET_COURSES: + break + courses.append(course) + seen.add(grounding.stop_signature(course)) + + if len(courses) >= TARGET_COURSES: + break + + if len(courses) < TARGET_COURSES: + notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다") + + LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내") + return courses, notes async def ensure_generated(place) -> dict: diff --git a/solution/backend/services/job_progress.py b/solution/backend/services/job_progress.py new file mode 100644 index 0000000..f392164 --- /dev/null +++ b/solution/backend/services/job_progress.py @@ -0,0 +1,34 @@ +"""작업 단계 기록. 실행 순서는 도메인 서비스, 화면 문구는 프론트가 소유한다.""" +from contextlib import asynccontextmanager + +from crud.job_crud import JobQueue + + +class JobProgress: + def __init__(self, job: dict, steps: tuple[str, ...]): + self.job = job + self.queue = JobQueue() + self.state = { + "attempt": job["attempts"], + "steps": [{"id": step, "status": "pending"} for step in steps], + } + + async def _set(self, step_id: str, status: str, reason: str | None = None): + step = next(step for step in self.state["steps"] if step["id"] == step_id) + step.update(status=status, reason=reason) + if not await self.queue.set_progress(self.job, self.state): + raise RuntimeError("작업 소유권이 만료되어 진행 기록을 중단합니다") + + @asynccontextmanager + async def step(self, step_id: str): + await self._set(step_id, "running") + try: + yield + except BaseException: + await self._set(step_id, "failed") + raise + else: + await self._set(step_id, "done") + + async def skip(self, step_id: str, reason: str): + await self._set(step_id, "skipped", reason) diff --git a/solution/backend/services/job_service.py b/solution/backend/services/job_service.py index 2ec6c3e..6dd1600 100644 --- a/solution/backend/services/job_service.py +++ b/solution/backend/services/job_service.py @@ -1,6 +1,7 @@ from fastapi import Depends -from common.enums import ErrorType, JobType +from common.enums import ErrorType, JobType, UserRole +from common.models.gmodel import UserInfo from common.logger import LOG from crud.job_crud import JobQueue from router.v1.job.protocol import JobData, Res_Job, Res_JobOps @@ -15,13 +16,34 @@ class JobService: def __init__(self, queue: JobQueue = Depends(JobQueue)): self.queue = queue - async def get_job(self, job_id: str) -> Res_Job: + async def get_job(self, job_id: str, user_info: UserInfo | None = None) -> Res_Job: + """잡 단건. ★ 주인이 아니면 **없는 것으로** 답한다(JOB_NOT_FOUND). + + ★ 잡 id 하나만 알면 남의 작업 결과가 열렸다(실측 2026-09-15). BUILD 결과에는 + site_id · 게이트 상세(비어 있는 필수 항목 목록) · payload 경로가 들어 있다. + 예전에는 COPY 만 주인을 봤는데, 가려야 할 것은 잡 종류가 아니라 **남의 사업장**이다. + ★ 주인을 알 수 없는 잡(내부 동기화·노래 등 payload 에 owner_user_id 가 없는 것)은 + 사장님에게 열지 않는다 — '주인이 없으니 아무나' 가 아니라 '확인할 수 없으니 닫는다' 다. + ★ user_info 가 None 인 호출은 내부 경로다(운영자 전용 requeue) — 그쪽은 이미 + RequireDeveloper 가 막는다. + """ res = Res_Job() row = await self.queue.get(job_id) if row is None: res.result.SetResult(ErrorType.JOB_NOT_FOUND) return res - res.job = JobData(**{k: v for k, v in row.items() if k in JobData.model_fields}) + payload = row.get("payload") or {} + if user_info is not None and (user_info.role or 0) < UserRole.DEVELOPER.value: + owner = payload.get("owner_user_id") + if owner is None or str(owner) != str(user_info.user_id): + res.result.SetResult(ErrorType.JOB_NOT_FOUND) + return res + fields = {k: v for k, v in row.items() if k in JobData.model_fields} + # 어느 사업장의 작업인지는 종류를 가리지 않고 싣는다 — 화면이 "이 화면의 작업이 맞나" 를 + # 이 값으로 판단한다(useGenerationJob 의 wrongJob). + if payload.get("place_id") is not None: + fields["place_id"] = payload.get("place_id") + res.job = JobData(**fields) return res async def ops(self) -> Res_JobOps: diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 95c1143..e30d3b6 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -230,6 +230,21 @@ class LocalContentService: if prev is None or prev.body != shared_body: changed += 1 + # ★ TourAPI 가 만들지 않은 연결(예: NAVER_CRAWL — services/local_restaurant_enrichment.py)은 + # 이 함수가 모르는 소스다. "이번 응답에 없으니 끊는다"를 그대로 적용하면 다른 파이프라인이 + # 붙여 둔 것까지 매 재수집마다 지웠다가 그쪽이 다시 채우는 낭비가 생긴다(2026-09-14 실측: + # 네이버로 크롤링해 둔 맛집이 다음 TourAPI 재수집 때마다 끊겼다 붙었다 했다). + # 그래서 TourAPI 소스가 아닌 기존 연결은 항상 kept_ids/personal 에 그대로 얹어 보존한다. + for key, row in existing.items(): + if int(getattr(row, "source", LocalSource.TOUR_API.value)) == LocalSource.TOUR_API.value: + continue + kept_ids.add(row.local_content_id) + personal.setdefault(str(row.local_content_id), { + "kind": AREA_KIND.get(int(row.content_type)), + "distanceMeters": row.distance_m, + "hidden": bool(row.hidden), + }) + # 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다. _, removed = await DB_SESSION_MNG.execute_lambda_claim( place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids) diff --git a/solution/backend/services/local_restaurant_enrichment.py b/solution/backend/services/local_restaurant_enrichment.py new file mode 100644 index 0000000..3e93fac --- /dev/null +++ b/solution/backend/services/local_restaurant_enrichment.py @@ -0,0 +1,239 @@ +"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다. + +★ TourAPI 로 이미 있는 맛집은 몇 건이든(군산 절골길 18처럼 59건이어도) 그대로 전부 보여준다 — + 이 모듈은 그중 어떤 것도 지우거나 숨기지 않는다(2026-09-14 사용자 확정). Perplexity 지역검색 + 상위 10개 이름 중 TourAPI(또는 이전에 이미 크롤링해 둔 것)에 없는 이름만 네이버에서 크롤링해 + **추가**한다 — "상위 10개"는 Perplexity 검색 후보의 상한일 뿐, 최종 화면에 보이는 개수의 + 상한이 아니다. +★ 네이버 URL 확보는 `services/external/naver_place_lookup.py`(상호명+지역으로 네이버 자체 검색 → + place id)를 그대로 재사용한다 — Perplexity 도메인필터 재검색으로 시도했다가 실측(2026-09-14, + 군산시 6곳 중 0곳 성공)에서 명중률이 낮아 이 기존 모듈로 바꿨다. +★ docs/DECISIONS.md 1-1 예외 처리. 설계: tmp/superpowers/specs/2026-09-14-nearby-restaurant-naver-enrichment-design.md + 봇 탐지 우회는 하지 않는다 — 막히면 그 업체만 포기한다. +""" +import re +from datetime import datetime, timezone + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import area_contents, place_area_refs +from common.enums import AREA_KIND, DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource +from common.logger import LOG +from common.utils.geo import haversine_m +from crud.place_content_crud import PlaceContentCRUD +from services.collector.naver_place_adapter import NaverPlaceAdapter +from services.external import naver_place_lookup, perplexity +from services.external.restaurant_discovery import search_region_restaurants + +_NORM_STRIP = re.compile(r"[\s,·.\-_'\"()&]") + +_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude") + + +def normalize_name(name: str) -> str: + """비교용 정규화. 공백·구두점·괄호를 지우고 소문자로 낮춘다.""" + return _NORM_STRIP.sub("", (name or "")).lower() + + +def is_same_restaurant(a: str, b: str) -> bool: + """이름 유사도 판정. 표기 차이와 지점명 접미사("...본점")는 같은 곳으로 본다. + + ★ 부분 문자열 포함으로 판정한다 — 완전 일치만 보면 "이든식당"과 "이든식당 본점"이 + 다른 곳으로 갈려 TourAPI에 이미 있는 곳을 중복으로 다시 크롤링한다. + """ + na, nb = normalize_name(a), normalize_name(b) + if not na or not nb: + return False + return na == nb or na in nb or nb in na + + +def to_area_content_body(summary: dict) -> dict: + """NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict. + + ★ distance_m 은 항상 None 이다 — 지역명 검색으로 찾은 업체라 업장 좌표 기준 거리를 + 모른다. local_content_service 의 body["distance_m"] 직접 접근 규약을 지키려면 + 키 자체는 있어야 한다(값만 비운다). + """ + out = { + "contentid": summary["place_id"], + "content_type": LocalContentType.RESTAURANT.value, + "distance_m": None, + "name": summary["name"], + "searchQuery": summary["name"], + } + if summary.get("address"): + out["location"] = summary["address"] + if summary.get("latitude"): + out["latitude"] = summary["latitude"] + if summary.get("longitude"): + out["longitude"] = summary["longitude"] + if summary.get("imageUrl"): + out["imageUrl"] = summary["imageUrl"] + return out + + +def _as_float(value) -> float | None: + try: + return float(value) if value not in (None, "") else None + except (TypeError, ValueError): + return None + + +def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) -> int | None: + """업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m). 둘 중 하나라도 없으면 None. + + ★ 외부 호출 없는 순수 계산이다 — 좌표는 이미 fetch_summary()가 같은 응답에서 받아 온 + 값이라 이 계산에 드는 비용은 없다(common.utils.geo.haversine_m 재사용). + """ + if place_coords is None: + return None + lat, lng = _as_float(summary.get("latitude")), _as_float(summary.get("longitude")) + if lat is None or lng is None: + return None + return round(haversine_m(place_coords[0], place_coords[1], lat, lng)) + + +async def _place_coordinates(place_id) -> tuple[float, float] | None: + """이 place 자신의 좌표. 크롤링한 맛집과의 거리 계산용으로만 쓴다. + + ★ 순환 import 회피: story_service → 이 모듈로 이어지는 사슬이 있어 지연 import 한다. + """ + from services.local_content_service import LocalContentService + + place = await LocalContentService()._load_place(place_id) + if place is None: + return None + lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None)) + if lat is None or lng is None: + return None + return lat, lng + + +async def _sync_site_personalization(place_id, restaurant_refs: list) -> None: + """`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는 + 것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다. + + restaurant_refs: [(content_id, distance_m, hidden), ...] — 기존 연결 + 이번에 새로 + 크롤링한 것 전부. 기존 것이 이미 맵에 있으면 손대지 않고, 없는 것(새로 추가한 것, + 또는 과거에 맵 갱신 없이 만들어진 것)만 채운다 — 그래서 자연히 자가복구도 된다. + + ★ 캔버스(스냅샷)는 `place_area_refs`가 아니라 이 맵만 읽는다(services/snapshot.py:: + _site_places). `place_area_refs`에만 쓰고 여기를 안 채우면, DB에는 들어가도 화면에는 + 안 나온다. + ★ 순환 import 회피: local_content_service → story_service → 이 모듈로 이어지는 사슬이 있어 + 지연 import 한다(story_service.run_local_sync 의 관례와 동일). + """ + if not restaurant_refs: + return + from services.local_content_service import LocalContentService + from services.snapshot import _site_places + + places_map = dict(await _site_places(place_id)) + changed = False + for content_id, distance_m, hidden in restaurant_refs: + key = str(content_id) + if key in places_map: + continue + places_map[key] = {"kind": "restaurant", "distanceMeters": distance_m, "hidden": bool(hidden)} + changed = True + if changed: + await LocalContentService()._write_site_places(place_id, places_map) + + +async def enrich_place_restaurants(place_id, region_label: str, region_code: str | None = None) -> dict: + """이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중 + 아직 없는 곳만 네이버에서 크롤링해 추가한다. 기존 연결을 지우거나 숨기지 않는다. + + 흐름: ① Perplexity 로 이 지역 맛집 상위 10개 이름을 받는다 → ② 이름마다 이 place 에 이미 + 연결된 맛집(TourAPI 또는 이전 크롤링분)과 유사도 매칭 — 있으면 건너뛰고(중복 크롤링 방지), + 없으면 네이버에서 크롤링해 새로 연결한다. 실패해도 예외를 던지지 않는다. + """ + stats = {"matched": 0, "added": 0, "checked": 0, "skipped": ""} + + if not perplexity.is_configured(): + stats["skipped"] = "PERPLEXITY_API_KEY 미설정" + return stats + if not (region_label or "").strip(): + stats["skipped"] = "region_label 없음" + return stats + + names = await search_region_restaurants(region_label) + if not names: + stats["skipped"] = "Perplexity 검색 결과 없음" + return stats + + crud = PlaceContentCRUD() + err, rows = await DB_SESSION_MNG.execute_lambda( + place_area_refs.DBType(), DBWRType.DB_READ.value, + lambda s: crud.list_by_place(s, place_id), + ) + if err != ErrorType.SUCCESS: + stats["skipped"] = "기존 목록 조회 실패" + return stats + + existing = [r for r in (rows or []) if int(r.content_type) == LocalContentType.RESTAURANT.value] + existing_titles = [r.title for r in existing if r.title] + now = datetime.now(timezone.utc) + place_coords = await _place_coordinates(place_id) + new_entries: list = [] # [(content_id, distance_m), ...] + + for name in names: + stats["checked"] += 1 + if any(is_same_restaurant(name, title) for title in existing_titles): + stats["matched"] += 1 + continue + + naver_id = await naver_place_lookup.find_place_id(name, region_label) + if not naver_id: + continue + + summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(naver_id)) + if not summary: + # ★ naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서 + # 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다. + LOG.w(f"[restaurant_enrich] '{name}' place={naver_id} 상세 조회 실패 — 포기") + continue + + body = to_area_content_body(summary) + content_values = { + "source": LocalSource.NAVER_CRAWL.value, + "external_id": body["contentid"], + "content_type": body["content_type"], + "kind": AREA_KIND.get(body["content_type"]), + "title": body["name"], + "body": {k: v for k, v in body.items() if k not in _BODY_DROP}, + "latitude": _as_float(body.get("latitude")), + "longitude": _as_float(body.get("longitude")), + "region_code": region_code, + "status": LocalContentStatus.PUBLISHED.value, + "display_end_at": None, + "collected_at": now, + } + write_err = await DB_SESSION_MNG.execute_lambda_run( + [area_contents.DBType()], + [lambda s, v=content_values: crud.upsert_content(s, v)], + ) + if write_err != ErrorType.SUCCESS: + continue + err_i, rows_i = await DB_SESSION_MNG.execute_lambda( + area_contents.DBType(), DBWRType.DB_READ.value, + lambda s, e=body["contentid"]: crud.find_content_id(s, LocalSource.NAVER_CRAWL.value, e), + ) + if err_i != ErrorType.SUCCESS or not rows_i: + continue + content_id = rows_i[0] + distance_m = _distance_to_place(place_coords, summary) + await DB_SESSION_MNG.execute_lambda_run( + [place_area_refs.DBType()], + [lambda s, cid=content_id, d=distance_m: crud.upsert_ref(s, place_id, cid, d)], + ) + + existing_titles.append(name) + new_entries.append((content_id, distance_m)) + stats["added"] += 1 + LOG.i(f"[restaurant_enrich] place={place_id} '{name}' 네이버 크롤링으로 추가" + f"{f' (거리 {distance_m}m)' if distance_m is not None else ''}") + + restaurant_refs = [(r.local_content_id, r.distance_m, r.hidden) for r in existing] + restaurant_refs += [(cid, dist, False) for cid, dist in new_entries] + await _sync_site_personalization(place_id, restaurant_refs) + return stats diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 34f26d7..c796431 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -295,28 +295,55 @@ class PlaceService: sess, uuid.UUID(user_info.user_id), ExternalPlaceSource.NAVER.value, str(naver_id), ), ) - canonical_id = place_id + # + # ★ **사장님이 새로 만들겠다고 누른 것은 뒤집지 않는다** (2026-09-15) + # 위 합치기는 "위저드를 다시 시작하면 빈 행이 쌓인다" 를 막으려던 것인데, + # [새로 크롤링하고 사이트 생성하기] 와 구분이 없어서 **일부러 다시 만들려는 경우도 + # 기존 사업장으로 끌고 갔다** — 새로 만들기를 눌렀는데 기존 에디터가 열린다. + # 그래서 이어붙이기는 `reuse_existing` 이 켜져 있을 때만 한다. + # + # 끄더라도 **비어 있는 중복 행은 치운다.** 원래 막으려던 누적이 그것이고, + # 빈 행은 잃을 것이 없다. 지금 행은 앞으로 채워질 것이므로 대상이 아니다. + others = [] if err_dup == ErrorType.SUCCESS: - existing = next((r for r in (dup_rows or []) if str(r.place_id) != str(place_id)), None) - if existing is not None: - canonical_id = str(existing.place_id) - if await self._is_empty(place_id): + others = [r for r in (dup_rows or []) if str(r.place_id) != str(place_id)] + + if others and not req.reuse_existing: + for row in others: + if await self._is_empty(str(row.place_id)): await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), - lambda sess: self.crud.delete_place( - sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id), + lambda sess, rid=row.place_id: self.crud.delete_place( + sess, uuid.UUID(user_info.user_id), rid, ), ) - LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 " - f"{canonical_id} 로 잇는다 (naver {naver_id})") - else: - LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 " - f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})") - place_id = canonical_id - err_type, place = await self._load(user_info, place_id) - if err_type != ErrorType.SUCCESS: - res.result.SetResult(err_type) - return res + LOG.i(f"[verify_by_url] 새로 만들기 — 빈 중복 행 {row.place_id} 를 치운다 " + f"(naver {naver_id})") + LOG.i(f"[verify_by_url] 새로 만들기 — 같은 업소가 {len(others)}개 있지만 " + f"{place_id} 로 새로 간다 (naver {naver_id})") + others = [] + + canonical_id = place_id + if others: + existing = others[0] + canonical_id = str(existing.place_id) + if await self._is_empty(place_id): + await DB_SESSION_MNG.execute_lambda_claim( + places.DBType(), + lambda sess: self.crud.delete_place( + sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id), + ), + ) + LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 " + f"{canonical_id} 로 잇는다 (naver {naver_id})") + else: + LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 " + f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})") + place_id = canonical_id + err_type, place = await self._load(user_info, place_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res coord = base.get("coordinate") or {} verify_req = Req_VerifyPlace( @@ -904,21 +931,29 @@ class PlaceService: async def start_copy(self, user_info: UserInfo, place_id: str, req: Req_StartCopy) -> Res_StartCopy: """소개문·FAQ 생성을 큐에 넣는다. - ★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 — - 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다.""" + ★ 근거로 쓸 확인된 fact 가 없으면 LLM 은 부르지 않는다 — + 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다. + ★ 그래도 FAQ 카탈로그가 있는 업종(펜션)이면 잡을 만든다 — fact 가 0건이어도 FAQ 는 + 문의 안내로 20개를 채운다(DECISIONS.md 8절). 그 경로는 LLM 을 안 쓰므로 API 키도 필요 없다.""" from common.database.model.models import place_facts as facts_model + from common.faq_catalog import find_catalog from crud.fact_crud import FactCRUD from services.external import gemini_text res = Res_StartCopy() - err_type, _place = await self._load(user_info, place_id) + err_type, place = await self._load(user_info, place_id) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - if not gemini_text.is_configured(): - res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED) - return res + # URL 에 잡 ID 를 쓰기 전에 응답이 끊겨도 완료된 작업을 다시 만들지 않는다. + if req.resume: + existing = await self.queue.find_latest(f"copy:{place_id}") + if existing: + res.job_id = uuid.UUID(existing["job_id"]) + res.status = JobStatus(existing["status"]) + res.created = False + return res crud = FactCRUD() f_err, rows = await DB_SESSION_MNG.execute_lambda( @@ -930,9 +965,15 @@ class PlaceService: res.result.SetResult(f_err) return res res.grounded_facts = sum(1 for r in rows if r.unit_id is None and (r.value or "").strip()) - if res.grounded_facts == 0: + # 객실 fact 만 있어도 LLM 이 쓴다(copy_service 의 unit_summaries) — 키 검사는 그 기준으로 한다. + needs_llm = any((r.value or "").strip() for r in rows) + if not needs_llm and find_catalog(place.category, place.external_category) is None: + # 쓸 근거도, 채울 공통 질문도 없다(카페·음식점·호텔). res.result.SetResult(ErrorType.FAQ_UNGROUNDED) return res + if needs_llm and not gemini_text.is_configured(): + res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED) + return res job_id, created = await enqueue_job( self.queue, JobType.COPY, diff --git a/solution/backend/services/prompts/copy.py b/solution/backend/services/prompts/copy.py index 6a5a00d..b4d127f 100644 --- a/solution/backend/services/prompts/copy.py +++ b/solution/backend/services/prompts/copy.py @@ -56,6 +56,7 @@ def build_prompt( max_faqs: int, unit_facts: Optional[Sequence[FactLike]] = None, records: Optional[Sequence[str]] = None, + suggested_questions: Optional[Sequence[str]] = None, ) -> str: """소개문·메타·FAQ 생성 프롬프트. @@ -79,6 +80,14 @@ def build_prompt( "업소에 대해 확인된 기록(출처가 있는 글):", *(f"- {line}" for line in records), ]) + if suggested_questions: + # 업종 카탈로그 중 위 사실로 답할 수 있는 질문들(services/faq_fill.suggested_questions). + # ★ 채우기는 fact 가 있는 질문을 건너뛴다 — 여기서 모델이 안 쓰면 그 주제는 비어 버린다. + sections.extend([ + "", + "FAQ 로 먼저 쓸 질문(위 사실로 답할 수 있는 것):", + *(f"- {q}" for q in suggested_questions), + ]) sections.extend([ "", "출력:", @@ -96,6 +105,9 @@ def build_prompt( "- false·불가·없음 값을 가능하다고 표현하지 않는다.", "- 홍보성·평가성 표현을 쓰지 않는다.", "- 근거 없는 FAQ는 만들지 않는다.", + # ★ 실측(2026-09-14, 로컬): 노출 중인 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 처럼 두 주제를 묶었다. + # 묶으면 문항 수는 그대로인데 다룬 주제가 줄고, 채우기의 겹침 판정도 두 주제를 함께 지운다. + "- FAQ 한 문항에는 주제 하나만 묻는다(예: 체크인과 체크아웃을 한 문항에 묶지 않는다).", "- 한국어 존댓말을 사용한다.", ]) return "\n".join(sections) diff --git a/solution/backend/services/prompts/restaurant_search.py b/solution/backend/services/prompts/restaurant_search.py new file mode 100644 index 0000000..e4ae259 --- /dev/null +++ b/solution/backend/services/prompts/restaurant_search.py @@ -0,0 +1,33 @@ +"""Prompt contract for regional restaurant name discovery (Perplexity).""" + +SYSTEM_PROMPT = ( + "너는 지역 맛집 조사원이다. 실제로 영업 중인 음식점 상호명만 나열한다. " + "설명이나 추천 이유는 쓰지 않는다." +) + +RESPONSE_SCHEMA = { + "type": "json_schema", + "json_schema": { + "schema": { + "type": "object", + "properties": { + "restaurants": { + "type": "array", + "items": {"type": "string"}, + } + }, + "required": ["restaurants"], + } + }, +} + + +def build_prompt(region_label: str) -> str: + return "\n".join([ + f"지역: {region_label}", + "", + "이 지역에서 실제로 영업 중인 인기 음식점 상호명을 최대 10개 나열하라.", + "- 상호명만 반환하고 주소·메뉴·설명은 쓰지 마라.", + "- 폐업했거나 존재가 확실하지 않은 곳은 제외한다.", + "- 확실한 곳이 10개 미만이면 있는 만큼만 반환한다.", + ]) diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 533a2df..8f43bef 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -7,7 +7,7 @@ "label": "가요 다방", "maxItems": 8, "task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", - "rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n" + "rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**\n 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:\n 67곡 중 63곡이 비어 있었다).\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n" }, "daily": { "kind": "daily", @@ -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/render_report.py b/solution/backend/services/render_report.py index b9844b9..7c642fa 100644 --- a/solution/backend/services/render_report.py +++ b/solution/backend/services/render_report.py @@ -1,22 +1,18 @@ """렌더 보고서 — SSG 가 실제로 페이지를 구웠는지 확인하는 창구. ★ 왜 필요한가 - 발행 잡은 payload JSON 을 쓰는 것까지가 끝이고, 방문자가 볼 HTML 은 프론트의 SSG 가 굽는다. - 그 사이가 끊겨 있었다. 프리렌더가 깨져도 DB 에는 "발행됨"으로 남고, 페이지는 없는데 - 아무도 그걸 모른다 — 사장님이 [사이트 열기] 를 눌러야 404 로 발견된다. + BUILD 잡은 payload JSON 을 쓰고 렌더러를 직접 돌린다(services/render_service). 렌더러는 + 굽는 동안 있었던 일(성공/실패, 구조화 데이터 대조, 고유 콘텐츠 수)을 사이트마다 + `/.status/.json` 에 남긴다 — 백엔드에 마운트된 디렉토리가 payload + 디렉토리뿐이라 보고서도 그 안에 둔다. - 이제 프리렌더가 사이트마다 결과를 `/.status/.json` 에 쓴다. - 백엔드에 마운트된 디렉토리가 payload 디렉토리뿐이라 보고서도 그 안에 둔다. - -★ 여기서 하는 두 가지 - 1. 렌더 상태 — 구워졌는가, 언제, 실패했으면 왜 (사이트 조회 API 가 그대로 내보낸다) - 2. 발행 대기 — BUILD 잡이 이 버전의 보고서를 기다렸다가, 그 결과로 발행 여부를 정한다 - (wait_for). 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. +★ 여기서 하는 일 — 그 보고서를 읽는다. + render_service 가 렌더러 subprocess 를 기다린 **직후** 한 번 읽어 발행 여부를 정하고, + 사이트 조회 API(render_status)는 아무 때나 다시 읽어 "지금 이 버전이 구워져 있나" 를 보여준다. ★ 읽기 전용이다. 보고서가 없거나 깨져 있어도 예외를 밖으로 내보내지 않는다 — 사이트 조회가 부수 산출물 때문에 실패하면 안 된다. """ -import asyncio import json from pathlib import Path @@ -58,29 +54,6 @@ def read_report(slug: str) -> dict | None: return report -async def wait_for(slug: str, site_version: int, timeout_sec: float, poll_sec: float = 1.0) -> dict | None: - """이 버전의 렌더 보고서가 나올 때까지 기다린다. 시간 안에 안 나오면 None. - - ★ 왜 기다리나 - 발행 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 그 HTML 은 프론트 렌더러가 - 굽고, 렌더러는 payload 파일을 보고 도는 별도 프로세스다. 그래서 잡이 payload 를 쓴 뒤 - 보고서가 올 때까지 기다렸다가, 그 결과로 발행 여부를 정한다. - - ★ 버전이 맞는 보고서만 받는다. 직전 버전 보고서를 보고 통과시키면 이번에 바뀐 값은 - 아무도 검사하지 않은 채로 발행된다. - - ★ BUILD 잡은 heartbeat 로 lease 를 갱신하므로 여기서 기다려도 reaper 에 회수되지 않는다.""" - deadline = asyncio.get_running_loop().time() + timeout_sec - while True: - report = read_report(slug) - if report is not None and report.get("siteVersion") == site_version: - return report - if asyncio.get_running_loop().time() >= deadline: - LOG.w(f"[render] 보고서 대기 시간 초과 slug={slug} v{site_version} ({timeout_sec}초)") - return None - await asyncio.sleep(poll_sec) - - def render_status(slug: str, site_version: int | None = None) -> dict: """사이트 조회 API 가 그대로 내보내는 렌더 상태. diff --git a/solution/backend/services/render_service.py b/solution/backend/services/render_service.py new file mode 100644 index 0000000..f9dc15a --- /dev/null +++ b/solution/backend/services/render_service.py @@ -0,0 +1,153 @@ +"""렌더러 실행 — Python 워커가 미리 컴파일된 Node 렌더러를 subprocess 로 직접 돌린다. + +★ 왜 (프리렌더를 워커 실행으로 통합, 2026-09-15) + 예전에는 `solution-prerender` 라는 별도 상시 컨테이너가 payloads/ 디렉토리를 2초마다 + 폴링하며 바뀐 파일을 구웠고, BUILD 잡은 `.status/.json` 이 나타나기를 또 폴링했다 + (render_report.wait_for). 컨테이너 하나·폴링 두 겹이 전부 "굽는 걸 어떻게 아느냐"는 + 같은 문제를 풀고 있었다 — 워커가 굽기를 직접 돌리면 둘 다 필요 없다: subprocess 가 + 끝나는 순간이 곧 "구워졌다"는 신호다. + +★ 이 파일은 payload → 렌더러 실행 → 구조화된 결과, 그 경계만 다룬다. + Python 은 여전히 HTML 을 만들지 않는다(ARCHITECTURE 1절) — 여기서 하는 일은 미리 빌드된 + JS 파일 하나를 `node` 로 실행하고 그 결과 보고서(.status/.json, prerender.ts 가 쓴다) + 를 읽어 돌려주는 것뿐이다. 렌더러 코드 자체는 solution/site 소유 그대로다. + +★ 워커 이미지에는 컴파일된 렌더러(`dist/client` · `dist/prerender/prerender.js`)와 `public/` + 만 있으면 된다 — node_modules 는 필요 없다(vite.config.ts `ssr.noExternal: true`, 실측: + node_modules 를 지우고 실행해도 그대로 돈다). 기동·잡 실행 중에 npm install 을 하지 않는다. +""" +import asyncio +import fcntl +import os +from pathlib import Path + +from common.logger import LOG +from services import render_report + +# 컴파일된 렌더러 진입점. Dockerfile 의 site-builder 스테이지가 여기에 굽는다. +RENDERER_ENTRY_ENV = "RENDERER_ENTRY" +DEFAULT_RENDERER_ENTRY = "/app/solution/site/dist/prerender/prerender.js" + +NODE_BIN_ENV = "NODE_BIN" +DEFAULT_NODE_BIN = "node" + +# 렌더러가 굽는 산출물 루트(out/). services/azure_static·indexnow 와 **같은 env** 를 본다 — +# 렌더러가 쓴 자리와 백엔드가 훑는 자리가 어긋나면 "구웠는데 없다"는 조용한 실패가 난다. +OUTPUT_DIR_ENV = "SITE_OUTPUT_DIR" +DEFAULT_OUTPUT_DIR = "/app/solution/site/out" + + +class RenderFailed(RuntimeError): + """렌더러 프로세스 자체가 죽었거나(비정상 종료) 보고서를 남기지 못했다. + + ★ 게이트 반려(구조화 데이터 불일치·고유 콘텐츠 0건)는 여기 안 걸린다 — 그건 exit code + 1 이어도 보고서가 정상적으로 남으므로 호출측이 report.ok 로 판단한다. 이 예외는 + "무슨 일이 있었는지조차 모른다" 는 경우만 위한 것이다.""" + + +def renderer_entry() -> str: + return os.environ.get(RENDERER_ENTRY_ENV) or DEFAULT_RENDERER_ENTRY + + +def output_dir() -> str: + return os.environ.get(OUTPUT_DIR_ENV) or DEFAULT_OUTPUT_DIR + + +async def _run_subprocess(args: list[str], timeout_sec: float) -> tuple[int | None, str]: + """같은 볼륨의 자산 대장·사이트맵은 한 프로세스만 갱신한다.""" + root = Path(output_dir()) + root.mkdir(parents=True, exist_ok=True) + with (root / ".render.lock").open("a") as lock: + try: + async with asyncio.timeout(timeout_sec): + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + await asyncio.sleep(0.1) + return await _run_unlocked(args, timeout_sec) + except TimeoutError: + return None, "렌더 대기 또는 실행 시간 초과" + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +async def _run_unlocked(args: list[str], timeout_sec: float) -> tuple[int | None, str]: + """렌더러 프로세스를 돌리고 (exit_code, stderr 일부) 를 돌려준다. + + ★ 타임아웃이면 프로세스를 죽이고 회수한다 — 좀비로 남겨 워커 컨테이너의 프로세스 표를 + 채우면 안 된다. exit_code 는 None 으로 남아 "시간 안에 안 끝났다" 를 구분한다.""" + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + _stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) + except (asyncio.TimeoutError, TimeoutError): + proc.kill() + # kill 후에도 반드시 wait 한다 — 안 하면 좀비 프로세스로 남는다. + await proc.wait() + return None, f"{timeout_sec:.0f}초 안에 끝나지 않아 강제 종료했다" + except asyncio.CancelledError: + if proc.returncode is None: + proc.kill() + await proc.wait() + raise + # 사유는 앞부분에 나온다(watch-payloads.mjs 의 run() 과 같은 규칙) — 스택 전체를 들 이유가 없다. + return proc.returncode, stderr.decode("utf-8", errors="replace")[:4000] + + +async def render_site(payload_path: str, site_version: int, timeout_sec: float) -> dict: + """payload 파일 하나를 굽는다. 끝나면 렌더 보고서(dict)를 돌려준다. + + ★ 게이트 판정은 여기서 하지 않는다 — 반환된 보고서의 `ok`·`mismatches`· + `uniqueContentCount` 를 build_service.run_build 가 그대로 판단한다(예전과 같은 계약, + render_report.wait_for 가 폴링해서 얻던 것과 같은 모양이다). + ★ 멱등이다 — 같은 payload 파일로 다시 부르면 같은 결과가 나온다(재시도 안전). 렌더러 + 자신이 사이트별로 실패를 격리하므로, 이미 성공한 사이트를 다시 구워도 결과는 같다.""" + slug = Path(payload_path).stem + # 재시도에서 같은 버전의 옛 성공 보고서를 이번 실행 결과로 읽지 않는다. + render_report.report_path(slug).unlink(missing_ok=True) + args = [ + os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN, + renderer_entry(), + f"--payload={payload_path}", + f"--out={output_dir()}", + "--stage-only", + ] + try: + code, stderr = await _run_subprocess(args, timeout_sec) + except OSError as ex: + raise RenderFailed(f"렌더러 실행 실패: {type(ex).__name__}") from ex + + report = render_report.read_report(slug) + if code in (0, 1) and report is not None and report.get("siteVersion") == site_version: + return report + + # ★ 프로세스가 죽거나 시간 초과였는데 보고서도 없으면(사이트를 하나도 돌기 전에 죽은 경우) + # 대신 실패로 남긴다 — 안 그러면 build_service 가 "렌더 결과를 못 받았다" 는 것만 알고 + # 진짜 사유(모듈 누락·OOM 등)는 워커 로그에만 남는다. + reason = ( + f"렌더러가 {timeout_sec:.0f}초 안에 끝나지 않았다" + if code is None + else f"렌더러가 비정상 종료했다(exit {code})" if code != 0 else "보고서를 남기지 못했다(버전 불일치 포함)" + ) + detail = f"{reason}" + (f" — {stderr.strip()}" if stderr.strip() else "") + LOG.w(f"[render] {slug} v{site_version}: {detail}") + raise RenderFailed(detail) + + +async def activate_site(slug: str, site_version: int) -> None: + """두 게이트를 통과한 뒤 Node가 공개 링크만 전환한다.""" + args = [ + os.environ.get(NODE_BIN_ENV) or DEFAULT_NODE_BIN, + renderer_entry(), f"--out={output_dir()}", f"--activate={slug}:{site_version}", + ] + try: + code, _stderr = await _run_subprocess(args, 30) + except OSError as ex: + raise RenderFailed(f"공개 전환 실행 실패: {type(ex).__name__}") from ex + if code != 0: + raise RenderFailed("공개 버전 전환 실패") diff --git a/solution/backend/services/rollback_service.py b/solution/backend/services/rollback_service.py new file mode 100644 index 0000000..86d2dca --- /dev/null +++ b/solution/backend/services/rollback_service.py @@ -0,0 +1,158 @@ +"""예전 버전으로 되돌리기 — ROLLBACK 잡이 하는 일. + + 대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환 + +★ 재수집·재생성을 하지 않는다. run_build 와 다른 점이 이것 하나다 — 대상 버전이 + 박제해 둔 snapshot(site_versions.snapshot)을 **그대로** 다시 payload 로 옮길 뿐이다. + 사장님이 그때 이미 확인하고 발행했던 값이므로, 지금 fact 상태가 바뀌었어도 롤백은 + 그 시점의 값을 그대로 복원한다 — "롤백했는데 최신 수정이 섞여 있다"를 막는다. + +★ 그래도 게이트는 다시 돈다. 스냅샷은 그대로지만 **렌더러는 그때와 다를 수 있다** + (그 사이 배포됐을 수 있다) — 새 렌더러가 옛 스냅샷을 어떻게 굽는지는 다시 확인해야 한다. + +★ 재굽기가 필요 없을 수도 있다 — 대상 버전 디렉토리가 보관 기간 안에 아직 디스크에 있으면 + 렌더러가 그 사실을 알아서 이용한다(prerender.ts publishVersion 은 렌더 자체는 매번 다시 + 하지만, 결과가 같으면 파일 내용도 같다 — 멱등이다). 디스크에서 지워졌어도 문제없다 — + snapshot 이 있으니 처음부터 다시 굽는다. + +★ 실패하면 지금 공개된 버전은 그대로다. render_service.render_site 가 실패를 던지거나 + report.ok=False 면 심볼릭 링크를 아예 안 돌린다(prerender.ts publishVersion 은 성공한 + 렌더 결과에만 닿는다) — 발행 파이프라인과 같은 원자성 보장이다. +""" +import os +import uuid + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import places, site_publish_logs, site_versions, sites +from common.enums import ( + BuildStatus, + DBWRType, + ErrorType, + PlaceCategory, + PublishAction, + PublishResult, + SiteStatus, +) +from common.logger import LOG +from common.utils.gtime import GTime +from crud.place_crud import PlaceCRUD +from crud.site_crud import SiteCRUD +from services import azure_static, indexnow, publish_gate, render_service, site_payload +from services.build_service import ensure_site, load_channel_links + +_site_crud = SiteCRUD() +_place_crud = PlaceCRUD() + +RENDER_TIMEOUT_SEC_ENV = "RENDER_TIMEOUT_SEC" + + +class RollbackAborted(RuntimeError): + """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" + + +async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None): + row = site_publish_logs( + site_id=site_id, + site_version_id=version_id, + action=PublishAction.ROLLBACK.value, + result=result.value, + reject_reason=(gate.reason.value if gate is not None and gate.reason else None), + detail=(gate.as_log() if gate is not None and not gate.passed else None), + actor_user_id=uuid.UUID(actor) if actor else None, + ) + await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) + + +async def run_rollback(job: dict) -> dict: + """ROLLBACK 잡 핸들러. payload: {place_id, owner_user_id, target_version, requested_by?}""" + payload = job["payload"] + place_id = payload["place_id"] + owner_user_id = payload["owner_user_id"] + target_version = int(payload["target_version"]) + render_timeout_sec = float(os.environ.get(RENDER_TIMEOUT_SEC_ENV) or 180) + + err, place = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), + ) + if err != ErrorType.SUCCESS or place is None: + raise RollbackAborted(f"사업장을 찾을 수 없다: {place_id}") + + site = await ensure_site(place_id) + + v_err, version = await DB_SESSION_MNG.execute_lambda( + site_versions.DBType(), + DBWRType.DB_READ.value, + lambda s: _site_crud.get_version_by_number(s, site.site_id, target_version), + ) + if v_err != ErrorType.SUCCESS or version is None: + raise RollbackAborted(f"버전 {target_version} 을 찾을 수 없다") + if version.build_status != BuildStatus.BUILT.value or not version.snapshot: + raise RollbackAborted(f"버전 {target_version} 은 성공적으로 빌드된 적이 없어 되돌릴 수 없다") + if site.current_version_id == version.site_version_id: + raise RollbackAborted(f"이미 지금 공개 중인 버전이다(v{target_version})") + + result = { + "place_id": place_id, "site_id": str(site.site_id), + "target_version": target_version, "site_version_id": str(version.site_version_id), + } + + async def _fail(reason: str, gate: publish_gate.GateResult | None = None): + await _log(site.site_id, version.site_version_id, PublishResult.REJECTED if gate else PublishResult.FAILED, + gate, payload.get("requested_by")) + result["rolled_back"] = False + result["error"] = reason + LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}") + return result + + # ★ snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). + links = await load_channel_links(place_id) + payload_dict = await site_payload.prepare_site_payload( + place, version.snapshot, site, version, links, publish=True + ) + payload_path = site_payload.write_payload(payload_dict) + result["payload_path"] = payload_path + + try: + report = await render_service.render_site(payload_path, target_version, render_timeout_sec) + except render_service.RenderFailed as ex: + return await _fail(str(ex)) + + mismatches = list(report.get("mismatches") or []) + unique_count_raw = report.get("uniqueContentCount") + + # ★ 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — + # fact 검증 게이트(1차)는 건너뛴다. snapshot 은 이미 확인된 값만 담아 발행했던 것이라 + # 다시 물을 것이 없다 — 여기서 또 물으면 "그때는 됐는데 지금은 안 된다"는 모순이 난다. + gate = publish_gate.evaluate( + PlaceCategory(place.category), version.snapshot.get("facts") or [], unique_count_raw, mismatches + ) + result["gate"] = {"passed": gate.passed, **gate.as_log()} + if not gate.passed: + return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate) + if not report.get("ok"): + return await _fail(str(report.get("error") or "렌더 실패")) + + slug = site_payload.publish_slug(place, site) + try: + await render_service.activate_site(slug, target_version) + await azure_static.publish(slug) + except (render_service.RenderFailed, azure_static.AzurePublishError) as ex: + return await _fail(str(ex)) + await indexnow.submit(slug) + + now = GTime.UTC() + site_update = { + "status": SiteStatus.PUBLISHED.value, + "current_version_id": version.site_version_id, + "published_at": now, + } + await DB_SESSION_MNG.execute_lambda_claim( + sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update) + ) + await _log(site.site_id, version.site_version_id, PublishResult.SUCCESS, None, payload.get("requested_by")) + + result["rolled_back"] = True + LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") + return result diff --git a/solution/backend/services/search_console_alerts.py b/solution/backend/services/search_console_alerts.py new file mode 100644 index 0000000..13ad6bd --- /dev/null +++ b/solution/backend/services/search_console_alerts.py @@ -0,0 +1,45 @@ +"""Teams Workflow 수신용. 원문 오류/인증 정보는 메시지에 싣지 않는다.""" +from datetime import timedelta + +import httpx + +from common.logger import LOG + + +def alert_reason(row, now, days: int) -> str | None: + if row.alerted_at and now - row.alerted_at < timedelta(days=1): + return None + if row.error_code: + return f"조회/제출 실패: {row.error_code}" + if (row.inspection or {}).get("verdict") == "PASS": + return None + if now - row.published_at >= timedelta(days=days): + return f"발행 후 {days}일 이상 색인 미확인" + return None + + +async def send_alert(webhook_url: str, page_url: str, reason: str) -> bool: + if not webhook_url: + return False + if not webhook_url.startswith("https://"): + LOG.w("[search-console] ALERT_URL_INVALID") + return False + payload = {"type": "message", "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", "version": "1.2", "body": [ + {"type": "TextBlock", "text": "Google 색인 확인 필요", "weight": "Bolder"}, + {"type": "TextBlock", "text": page_url, "wrap": True}, + {"type": "TextBlock", "text": reason, "wrap": True}, + ]}, + }]} + try: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + response = await client.post(webhook_url, json=payload) + response.raise_for_status() + return True + except httpx.HTTPError: + # webhook 주소 자체가 인증 수단이므로 예외 문자열도 로그에 남기지 않는다. + LOG.w("[search-console] ALERT_DELIVERY_FAILED") + return False diff --git a/solution/backend/services/search_console_client.py b/solution/backend/services/search_console_client.py new file mode 100644 index 0000000..111d43b --- /dev/null +++ b/solution/backend/services/search_console_client.py @@ -0,0 +1,164 @@ +"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회. + + PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath} + POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect + +자세한 계약은 docs/SEARCH_CONSOLE_CLIENT.md. + +★ `SearchConsoleError` 는 `code` 문자열만 담는다 — Google 응답 본문·토큰·키·원본 + 예외 메시지는 절대 싣지 않는다. +★ `inspectionResult`·`indexStatusResult` 가 없거나 빈 응답은 "미색인"으로 + 넘겨짚지 않고 예외로 끊는다 — 검사 실패와 색인 결과를 구분 못하면 오판이 된다. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from urllib.parse import quote + +import httpx + +SITEMAPS_BASE = "https://www.googleapis.com/webmasters/v3/sites" +INSPECT_URL = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect" +SCOPES = ["https://www.googleapis.com/auth/webmasters"] +TIMEOUT_SEC = 20.0 +AUTH_REFRESH_TIMEOUT_SEC = 20.0 + + +class SearchConsoleError(RuntimeError): + """code 만 담는다 — Google 응답 본문·토큰·키는 여기 실으면 안 된다.""" + + def __init__(self, code: str): + self.code = code + super().__init__(code) + + +def _service_account_credentials(credentials_file: str): + """lazy import — 미설치여도 이 모듈의 import 자체는 죽지 않는다.""" + from google.oauth2 import service_account + + return service_account.Credentials.from_service_account_file(credentials_file, scopes=SCOPES) + + +def _refresh_sync(credentials) -> None: + """동기 토큰 갱신. `asyncio.to_thread` 로 감싸 부른다. + + `Request.__call__` 기본 타임아웃(120s)을 그대로 두면 만료된 키·막힌 네트워크에서 + 오래 걸릴 수 있다 — 매 요청에 상한을 강제로 덮어씌운다(호출측이 넘긴 값 포함).""" + import requests + from google.auth.transport.requests import Request + + with requests.Session() as session: + original_request = session.request + + def _request_with_timeout(*args, **kwargs): + kwargs["timeout"] = AUTH_REFRESH_TIMEOUT_SEC + return original_request(*args, **kwargs) + + session.request = _request_with_timeout + credentials.refresh(Request(session=session)) + + +def _status_error_code(status_code: int) -> str: + if status_code == 401: + return "unauthorized" + if status_code == 403: + return "forbidden" + if status_code == 429: + return "rate_limited" + if 500 <= status_code < 600: + return "server_error" + return f"http_{status_code}" + + +def _transport_error_code(ex: httpx.HTTPError) -> str: + if isinstance(ex, httpx.TimeoutException): + return "timeout" + return "transport_error" + + +class SearchConsoleClient: + def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): + self._credentials_file = credentials_file + self._client = httpx.AsyncClient(timeout=TIMEOUT_SEC, transport=transport) + self._credentials: Any = None + self._auth_lock = asyncio.Lock() + + async def __aenter__(self) -> "SearchConsoleClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._client.aclose() + + async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: + url = f"{SITEMAPS_BASE}/{quote(property_url, safe='')}/sitemaps/{quote(sitemap_url, safe='')}" + headers = await self._auth_headers() + res = await self._put(url, headers) + self._raise_for_status(res) + + async def inspect_url(self, property_url: str, page_url: str) -> dict[str, Any]: + headers = await self._auth_headers() + body = {"inspectionUrl": page_url, "siteUrl": property_url} + res = await self._post(INSPECT_URL, headers, body) + self._raise_for_status(res) + return _index_status_result(res) + + async def _put(self, url: str, headers: dict[str, str]) -> httpx.Response: + try: + return await self._client.put(url, headers=headers) + except httpx.HTTPError as ex: + raise SearchConsoleError(_transport_error_code(ex)) from None + + async def _post(self, url: str, headers: dict[str, str], body: dict[str, Any]) -> httpx.Response: + try: + return await self._client.post(url, headers=headers, json=body) + except httpx.HTTPError as ex: + raise SearchConsoleError(_transport_error_code(ex)) from None + + def _raise_for_status(self, res: httpx.Response) -> None: + if not res.is_success: + raise SearchConsoleError(_status_error_code(res.status_code)) + + async def _auth_headers(self) -> dict[str, str]: + token = await self._access_token() + return {"Authorization": f"Bearer {token}"} + + async def _access_token(self) -> str: + credentials = self._load_credentials() + if not getattr(credentials, "valid", False): + async with self._auth_lock: + if not getattr(credentials, "valid", False): + try: + await asyncio.to_thread(_refresh_sync, credentials) + except Exception: + raise SearchConsoleError("auth_failed") from None + token = getattr(credentials, "token", None) + if not token: + raise SearchConsoleError("auth_failed") + return token + + def _load_credentials(self) -> Any: + if self._credentials is None: + try: + self._credentials = _service_account_credentials(self._credentials_file) + except Exception: + raise SearchConsoleError("invalid_credentials_file") from None + return self._credentials + + +def _index_status_result(res: httpx.Response) -> dict[str, Any]: + try: + data = res.json() + except ValueError: + raise SearchConsoleError("invalid_json") from None + inspection_result = data.get("inspectionResult") if isinstance(data, dict) else None + if not isinstance(inspection_result, dict): + raise SearchConsoleError("missing_inspection_result") + index_status_result = inspection_result.get("indexStatusResult") + if not isinstance(index_status_result, dict) or not index_status_result: + raise SearchConsoleError("missing_index_status_result") + return index_status_result diff --git a/solution/backend/services/search_console_service.py b/solution/backend/services/search_console_service.py new file mode 100644 index 0000000..c1efd8a --- /dev/null +++ b/solution/backend/services/search_console_service.py @@ -0,0 +1,129 @@ +"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림. 발행 잡과 별도 트랜잭션이다.""" +import asyncio +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone + +import httpx + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType +from common.logger import LOG +from crud import search_console_crud as store +from services import site_payload +from services.search_console_alerts import alert_reason, send_alert +from services.search_console_client import SearchConsoleClient, SearchConsoleError +from services.search_console_settings import load_settings, belongs_to_property + + +async def run_scheduled_check(): + try: + settings = load_settings() + if settings is None: + return + await DB_SESSION_MNG.execute_lambda_write( + DBType.MAIN.value, lambda session: run_batch(session, settings), + ) + except Exception: + # 크론 실패가 API/발행에 전파되지 않고 다음 주기에 재시도된다. + LOG.w("[search-console] BATCH_FAILED — 설정 및 DB 마이그레이션 확인 필요") + + +async def run_batch(session, settings): + if not await store.lock_batch(session): + return + if not belongs_to_property(site_payload.publish_origin() + "/sitemap.xml", settings.property_url): + raise SearchConsoleError("PROPERTY_MISMATCH") + await register_publications(session, settings) + rows = await store.due_sites(session, settings.property_url) + if not rows: + return + async with SearchConsoleClient(settings.credentials_file) as client: + submitted = set() + for row in rows: + await check_site(client, row, settings, submitted) + await session.flush() + + +async def register_publications(session, settings): + for site, place, version in await store.new_publications(session, settings.property_url): + slug = site_payload.publish_slug(place, site) + page_url = f"{site_payload.publish_origin()}/s/{slug}" + if not belongs_to_property(page_url, settings.property_url): + LOG.w("[search-console] PROPERTY_MISMATCH") + continue + await store.register(session, { + "site_id": site.site_id, "site_version_id": version.site_version_id, + "property_url": settings.property_url, "page_url": page_url, + "published_at": site.published_at, + }) + + +async def read_sitemap(sitemap_url: str) -> set[str]: + try: + async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client: + async with client.stream("GET", sitemap_url) as response: + response.raise_for_status() + body = bytearray() + async for chunk in response.aiter_bytes(): + body.extend(chunk) + if len(body) > 5_000_000: + raise SearchConsoleError("SITEMAP_TOO_LARGE") + root = ET.fromstring(body) + return {(item.text or "").strip() for item in root.iter( + "{http://www.sitemaps.org/schemas/sitemap/0.9}loc" + )} + except (httpx.HTTPError, ET.ParseError): + raise SearchConsoleError("SITEMAP_UNAVAILABLE") from None + + +async def submit_sitemap(client, row, submitted: set): + if row.sitemap_submitted_at: + return + sitemap_url = site_payload.publish_origin() + "/sitemap.xml" + # 디스크가 아니라 실제 공개 URL을 확인한다. 업로드 지연 시 Google에 먼저 알리지 않는다. + urls = await read_sitemap(sitemap_url) + if row.page_url not in urls: + raise SearchConsoleError("URL_NOT_IN_SITEMAP") + if sitemap_url not in submitted: + await client.submit_sitemap(row.property_url, sitemap_url) + submitted.add(sitemap_url) + row.sitemap_submitted_at = datetime.now(timezone.utc) + + +def record_inspection(row, inspection: dict, now): + row.inspection = inspection + row.inspected_at = now + row.error_code = None + row.failures = 0 + if inspection.get("verdict") == "PASS": + row.first_indexed_at = row.first_indexed_at or now + row.next_check_at = now + timedelta(days=1) + + +def record_error(row, code: str, now): + row.error_code = code + row.failures = (row.failures or 0) + 1 + hours = min(24, 2 ** min(row.failures - 1, 5)) + row.next_check_at = now + timedelta(hours=hours) + + +async def check_site(client, row, settings, submitted: set): + now = datetime.now(timezone.utc) + try: + if not belongs_to_property(row.page_url, settings.property_url): + raise SearchConsoleError("PROPERTY_MISMATCH") + async with asyncio.timeout(90): + await submit_sitemap(client, row, submitted) + inspection = await client.inspect_url(row.property_url, row.page_url) + record_inspection(row, inspection, now) + except SearchConsoleError as ex: + record_error(row, ex.code, now) + except TimeoutError: + record_error(row, "CHECK_TIMEOUT", now) + except Exception: + record_error(row, "CHECK_FAILED", now) + reason = alert_reason(row, now, settings.alert_days) + if reason: + LOG.w(f"[search-console] site={row.site_id} {reason}") + if await send_alert(settings.alert_url, row.page_url, reason): + row.alerted_at = now diff --git a/solution/backend/services/search_console_settings.py b/solution/backend/services/search_console_settings.py new file mode 100644 index 0000000..8836feb --- /dev/null +++ b/solution/backend/services/search_console_settings.py @@ -0,0 +1,43 @@ +"""Google 로그인 설정과 분리한다. 서비스 계정 키는 서버 파일로만 읽는다.""" +import os +from dataclasses import dataclass +from urllib.parse import urlsplit + + +@dataclass(frozen=True) +class SearchConsoleSettings: + property_url: str + credentials_file: str + alert_url: str = "" + alert_days: int = 7 + + +def load_settings() -> SearchConsoleSettings | None: + if os.environ.get("GSC_ENABLED") != "1": + return None + prop = os.environ.get("GSC_PROPERTY_URL", "").strip() + key_file = os.environ.get("GSC_CREDENTIALS_FILE", "").strip() + if not prop or not key_file: + raise ValueError("GSC_CONFIG_MISSING") + parsed = urlsplit(prop) + domain_property = prop.startswith("sc-domain:") and bool(prop.removeprefix("sc-domain:")) + root_property = (parsed.scheme == "https" and parsed.hostname and parsed.path == "/" + and not parsed.query and not parsed.fragment and not parsed.username) + if not domain_property and not root_property: + raise ValueError("GSC_PROPERTY_INVALID") + days = int(os.environ.get("GSC_ALERT_DAYS", "7")) + if days < 1: + raise ValueError("GSC_ALERT_DAYS_INVALID") + return SearchConsoleSettings(prop, key_file, os.environ.get("GSC_ALERT_WEBHOOK_URL", ""), days) + + +def belongs_to_property(page_url: str, property_url: str) -> bool: + parsed = urlsplit(page_url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + return False + if parsed.query or parsed.fragment: + return False + if property_url.startswith("sc-domain:"): + domain = property_url.removeprefix("sc-domain:").lower() + return bool(domain) and (parsed.hostname == domain or parsed.hostname.endswith("." + domain)) + return page_url.startswith(property_url) diff --git a/solution/backend/services/seo_audit.py b/solution/backend/services/seo_audit.py index 9b4604d..fe947c6 100644 --- a/solution/backend/services/seo_audit.py +++ b/solution/backend/services/seo_audit.py @@ -6,6 +6,8 @@ """ from dataclasses import dataclass +from common.enums import SourceType + @dataclass(frozen=True) class AuditCheck: @@ -31,7 +33,8 @@ def _check(id, group, label, points, passed, detail, recommendation=None, partia def evaluate(snapshot: dict, *, verified: bool, site=None, version=None, ai_checks: list | None = None) -> dict: place = snapshot.get("place") or {} facts = snapshot.get("facts") or [] - faqs = snapshot.get("faqs") or [] + # 문의 안내(TEMPLATE)는 답이 아니다 — "질문형 FAQ" 에 세면 채우기만으로 만점이 난다. + faqs = [q for q in (snapshot.get("faqs") or []) if q.get("generated_by") != SourceType.TEMPLATE.value] media = snapshot.get("media") or [] built = bool(version and getattr(version, "built_at", None)) unique_count = int(getattr(version, "unique_content_count", 0) or 0) if version else 0 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/showcase_service.py b/solution/backend/services/showcase_service.py index 835ef8f..8f0cb1d 100644 --- a/solution/backend/services/showcase_service.py +++ b/solution/backend/services/showcase_service.py @@ -37,8 +37,11 @@ class ShowcaseService: # ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면 # 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳'). url=f"/s/{site_payload.publish_slug(place, site)}", - thumbnail_url=site.thumbnail_url, + # ★ sites.thumbnail_url 은 Azure 썸네일 저장소가 꺼져 있으면(로컬 개발 등) 비어 + # 있다 — 이 목록은 이미 PUBLISHED 만 걷었으므로, 그때는 빌더가 쓰는 대표 사진 + # (primary_photo_url)으로 대신 채운다(services/site_service._my_site_row 와 같은 규칙). + thumbnail_url=site.thumbnail_url or primary_photo_url, ) - for site, place in rows + for site, place, primary_photo_url in rows ] return res diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index cac2299..c6d8e83 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -23,6 +23,7 @@ from pathlib import Path from common.category_schema import get_schema from common.enums import ( + PUBLISHABLE_FACT_STATUSES, FactStatus, LinkChannel, LocalContentType, @@ -31,13 +32,21 @@ from common.enums import ( SourceType, ) from common.logger import LOG +from services.intro_summary import summarize_intro +from services.stay_guide import nol_stay_guide +from services.weather_notes import weather_notes # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 # 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다. +# ★ 기본값이 `solution/site/payloads` 아래인 이유 — 렌더러(prerender.ts)가 songs·out 디렉토리를 +# **자기 파일 위치 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..). 워커가 그 +# 렌더러를 subprocess 로 직접 띄우면서(render_service.py) 세 디렉토리(payloads·songs·out)가 +# 그 렌더러가 실제로 설치된 자리(`/app/solution/site/`) 아래에 나란히 있어야 한다 — +# 어긋나면 워커는 payload 를 잘 쓰는데 렌더러는 다른 곳에서 songs 를 찾다가 못 찾는다. PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR" -DEFAULT_PAYLOAD_DIR = "/app/out/payloads" +DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads" # 커스텀 도메인이 없을 때 쓰는 기본 호스트. sites.domain 이 채워지면 그 값이 이긴다. # @@ -137,7 +146,7 @@ _DEFAULT_THEME = { # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). - # ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 + # ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. # ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라 @@ -521,7 +530,7 @@ def _weather(row: dict): return { "temperature": temperature, "condition": _weather_condition(body.get("weather_code")), - # note 는 LLM 이 쓰는 안내 문구다. 지금 그걸 만드는 경로가 없으므로 넣지 않는다(지어내지 않는다). + **weather_notes(), "observedAt": observed_at, } @@ -615,6 +624,10 @@ def _local_place(row: dict, category: str): value = _text(body.get(key)) if value: entry[key] = value + # 수집한 소개를 버리면 stay 목업과 달리 실제 발행 카드에는 이름만 남는다. + description = _text(body.get("description")) or _text(body.get("overview")) + if description: + entry["description"] = description _put_distance(entry, body) return entry @@ -778,11 +791,15 @@ def region_label(*addresses: str | None) -> str | None: # ── payload 조립 ────────────────────────────────────────────────────────── -def to_site_payload(place, snapshot: dict, site, version, links) -> dict: +def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> dict: """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict). 순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고, - 같은 입력이면 언제나 같은 payload 가 나온다.""" + 같은 입력이면 언제나 같은 payload 가 나온다. + + ★ `publish` 는 렌더러(prerender.ts publishVersion)에게 "이 버전으로 공개 주소를 + 넘겨도 되는가"를 알리는 신호다. False(미리보기·게이트 통과 전 재빌드)면 렌더러가 + `out/versions/<slug>/<version>/` 에만 굽고 `out/s/<slug>` 심볼릭 링크는 그대로 둔다.""" snapshot = snapshot or {} snap_place = snapshot.get("place") or {} place_id = str(_get(place, "place_id")) @@ -869,6 +886,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: "url": url, "title": _text(_get(row, "title")) or _CHANNEL_TITLE.get(int(channel), "채널"), "confirmed": _get(row, "confirmed_at") is not None, + **({"stayGuide": nol_stay_guide(url, _get(row, "raw"))} + if category == PlaceCategory.LODGING.value + and _get(row, "confirmed_at") is not None + and int(channel) == LinkChannel.YANOLJA.value else {}), }) # ── 소개문 ─────────────────────────────────────────── @@ -923,6 +944,7 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: "publishedAt": published_at or updated_at, "updatedAt": updated_at, "version": int(_get(version, "version") or 1), + "publish": bool(publish), }, "place": { "name": name, @@ -983,9 +1005,26 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: for r in snapshot.get("social_posts", [])], "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 @@ -1017,14 +1056,31 @@ def write_payload(payload: dict) -> str: return str(path) -def emit_payload(place, snapshot: dict, site, version, links) -> str | None: +async def prepare_site_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> dict: + """미리보기·발행 공통 보강. 요약은 DB 스냅샷이 아니라 응답/산출물에만 싣는다.""" + payload = to_site_payload(place, snapshot, site, version, links, publish) + for fact in payload["facts"]: + if fact["key"] != "intro" or fact["status"] not in {s.value for s in PUBLISHABLE_FACT_STATUSES}: + continue + try: + summary = await summarize_intro(fact["key"], fact["value"]) + except Exception as ex: + # 부가 요약 실패 때문에 미리보기·발행까지 막히면 원문도 읽을 수 없게 된다. + LOG.w(f"[payload] 소개 요약 실패(원문 사용): {type(ex).__name__}") + continue + if summary and summary.strip(): + fact["summary"] = summary.strip() + return payload + + +async def emit_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> str | None: """payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다. ★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고, payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서 발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다.""" try: - payload = to_site_payload(place, snapshot, site, version, links) + payload = await prepare_site_payload(place, snapshot, site, version, links, publish) path = write_payload(payload) LOG.i( f"[payload] {payload['site']['slug']} → {path} " diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 9197f07..213e328 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -29,6 +29,7 @@ from router.v1.site.protocol import ( Req_SiteStatus, Req_SiteTemplate, Req_SiteTheme, + Req_Rollback, Req_StartBuild, Res_MySites, Res_PublishLogs, @@ -163,11 +164,11 @@ class SiteService: "빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다. ★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수 - (`snapshot.build_snapshot` → `site_payload.to_site_payload`)를 그대로 거치므로, + (`snapshot.build_snapshot` → `site_payload.prepare_site_payload`)를 그대로 거치므로, 여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다. """ - from services.build_service import ensure_site, _load_links - from services.site_payload import to_site_payload + from services.build_service import ensure_site, load_channel_links + from services.site_payload import prepare_site_payload from services.snapshot import build_snapshot err_type, place = await self._load_place(user_info, place_id) @@ -175,11 +176,11 @@ class SiteService: return None site = await ensure_site(place_id) - links = await _load_links(place_id) + links = await load_channel_links(place_id) snapshot = await build_snapshot(place) # ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은 # 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다). - return to_site_payload(place, snapshot, site, None, links) + return await prepare_site_payload(place, snapshot, site, None, links) # ---- 사이트 주소(네임스페이스) --------------------------------------- # 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다. @@ -216,10 +217,15 @@ class SiteService: return reason, None value = (slug or "").strip() + # ★ 주인 확인이 예약어보다 먼저다. 예약어 목록은 나중에 늘어나는데(목업 슬러그를 막는 + # 것처럼), 늘린 이름을 이미 쓰고 있던 사장님이 자기 편집 화면을 여는 순간 + # "이 주소는 못 씁니다" 가 뜬다 — 바꿀 수도 없는 주소다(발행 뒤 SITE_SLUG_LOCKED). + owner = await self._domain_owner(value) + if owner is not None and owner == str(uuid.UUID(place_id)): + return None, None if reason is None: - owner = await self._domain_owner(value) # ★ 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. - if owner is None or owner == str(uuid.UUID(place_id)): + if owner is None: return None, None reason = site_slug.REASON_TAKEN return reason, await self._suggest(value) @@ -227,13 +233,25 @@ class SiteService: async def check_slug(self, user_info: UserInfo, place_id: str, slug: str) -> Res_SlugCheck: """주소를 쓸 수 있는지 미리 본다. - ★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고르고, 겹치면 고르기 전에 알려 준다.""" + ★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고르고, 겹치면 고르기 전에 알려 준다. + ★ 확인과 저장은 같은 판정을 해야 한다. 발행 잠금(SITE_SLUG_LOCKED)을 여기서 안 보면 + "쓸 수 있습니다" 라고 답해 놓고 저장에서 1707 로 튕긴다 — 지금은 발행 모달이 + 입력칸을 잠가 가려져 있을 뿐이고, API 를 직접 쓰는 쪽에는 그대로 드러난다.""" res = Res_SlugCheck() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res + site = await self._get_site(place_id) + if site is not None and (site.published_at is not None or site.status == SiteStatus.PUBLISHED.value): + if (site.domain or "") != (slug or "").strip(): + res.available = False + res.reason = site_slug.REASON_LOCKED + return res + res.available = True + return res + reason, suggestion = await self._judge_slug(place_id, slug) res.available = reason is None res.reason = reason @@ -459,14 +477,24 @@ class SiteService: if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows] + res.sites = [ + self._my_site_row(place, site, built_at, primary_photo_url) + for place, site, built_at, primary_photo_url in rows + ] res.total = total return res @staticmethod - def _my_site_row(place, site, built_at) -> MySiteData: + def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData: # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. changed = place.content_updated_at + # ★ sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다 + # (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다. + # 그래도 "한 번이라도 발행한 줄은 그림"이라는 화면 규칙은 지켜야 하므로, 빌더가 이미 + # 쓰고 있는 대표 사진(place_photos, primary_photo_url)으로 대신 채운다 — 발행 전 줄에는 + # 쓰지 않는다(그 규칙은 published_at 유무로 가른다: crud.site_crud.list_owner_sites). + ever_published = site is not None and getattr(site, "published_at", None) is not None + thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None) return MySiteData( place_id=place.place_id, name=place.name, @@ -479,7 +507,7 @@ class SiteService: domain=getattr(site, "domain", None), template_id=getattr(site, "template_id", None), published_at=getattr(site, "published_at", None), - thumbnail_url=getattr(site, "thumbnail_url", None), + thumbnail_url=thumbnail_url, needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), ) @@ -549,6 +577,26 @@ class SiteService: res.result.SetResult(ErrorType.PLACE_NOT_VERIFIED) return res + # ★ publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다. 한 번도 발행한 적 + # 없는 사업장에는 쓸 수 없다. + # 왜 막나 — 빌드는 게이트를 판정하려고 payload 를 디스크에 쓰고, 프리렌더는 거기 + # 생긴 것을 곧바로 공개 페이지로 굽는다(sitemap · llms.txt · /s 목록 포함). + # 그래서 publish=False 인데도 페이지가 공개됐고, DB 는 DRAFT·published_at=NULL 이라 + # 빌더 화면은 "발행 전" 으로 보였다. 2차 게이트(고유 콘텐츠 · JSON-LD)는 구운 결과를 + # 봐야 판정하므로 **거부돼도 페이지는 이미 나가 있었다**(실측 2026-09-15). + # 발행 전에 보고 싶은 것은 `GET /site/preview` 가 준다 — 그쪽은 디스크를 건드리지 않는다. + site = await self._get_site(place_id) + if not req.publish: + if site is None or site.published_at is None: + res.result.SetResult(ErrorType.SITE_VERSION_NOT_FOUND) + return res + + # ★ 주소를 안 골랐어도 막지 않는다. 그때 쓰는 임시 슬러그(`slugify(상호명)-place_id[:8]`)에 + # 한글이 남는 것은 실수가 아니라 결정이다 — 음차하면 같은 가게가 두 주소를 갖는다 + # (`site_payload.slugify` 주석). 사람이 고르는 주소만 영문으로 제한한다. + # 발행 모달은 주소를 먼저 받게 되어 있고(PublishModal), API 로 건너뛰면 임시 주소로 + # 나간 뒤 SITE_SLUG_LOCKED 로 잠긴다는 점은 그대로다. + job_id, created = await enqueue_job( self.queue, JobType.BUILD, { @@ -570,6 +618,35 @@ class SiteService: res.created = created return res + async def start_rollback(self, user_info: UserInfo, place_id: str, req: Req_Rollback) -> Res_StartBuild: + """예전 버전으로 공개 주소를 되돌리는 잡을 큐에 넣는다. + + ★ dedupe_key 를 빌드와 **같은 것**(`build:{place_id}`)을 쓴다 — 롤백 도중에 새 발행이 + 끼어들거나 그 반대가 되면 어느 쪽이 이겼는지 알 수 없는 상태가 된다. 이 사이트의 + 공개 주소를 바꾸는 작업은 항상 하나만 활성화된다.""" + res = Res_StartBuild() + err_type, _place = await self._load_place(user_info, place_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + job_id, created = await enqueue_job( + self.queue, JobType.ROLLBACK, + { + "place_id": place_id, "owner_user_id": user_info.user_id, + "target_version": req.target_version, "requested_by": user_info.user_id, + }, + dedupe_key=f"build:{place_id}", + ) + if job_id is None: + res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING) + return res + + res.job_id = uuid.UUID(job_id) + res.status = JobStatus.PENDING + res.created = created + return res + async def list_versions(self, user_info: UserInfo, place_id: str) -> Res_SiteVersions: from sqlalchemy import select diff --git a/solution/backend/services/site_slug.py b/solution/backend/services/site_slug.py index 08c0424..99623c6 100644 --- a/solution/backend/services/site_slug.py +++ b/solution/backend/services/site_slug.py @@ -48,6 +48,14 @@ RESERVED_SLUGS = frozenset({ "search", "index", "home", "main", "new", "edit", "delete", "create", "update", # 값이 비었을 때 프론트가 문자열로 흘려보내는 것들 — 주소로 들어오면 버그의 흔적이다 "null", "undefined", "none", "nan", "true", "false", + # ★ 손으로 만든 목업이 쓰는 주소(solution/site/out/s, mockup/README). + # payload 가 없어 프리렌더가 굽지 않는 자리인데, 사장님이 이 주소로 발행하면 + # **payload 가 생기는 순간** 덮여서 유일본이 영영 사라진다(AGENTS.md ★★ 항목). + # 실측(2026-09-15): `stay` 는 주인이 있어 우연히 TAKEN 이었을 뿐이고 + # `stay2` · `stay3` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다. + # ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 — + # 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다. + "stay", "stay2", "stay3", }) diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 391993a..bb4a74c 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -32,6 +32,7 @@ from common.enums import ( ErrorType, FactStatus, LocalContentStatus, + LocalContentType, LocalSource, MediaStatus, PlaceCategory, @@ -43,7 +44,7 @@ from services.external.naver import region_key _PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES) # 지역 정보를 종류별로 몇 건까지 박제할지. -# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정). +# ★ 관광지·축제·코스 노출 상한. 맛집은 수집된 전체를 발행한다(2026-09-14). # 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다. # 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다. _LOCAL_MAX_PER_TYPE = 20 @@ -260,7 +261,7 @@ async def _local_contents(place) -> dict: .order_by(area_contents.content_type.asc(), area_contents.collected_at.desc()) ) err, rows = await DB_SESSION_MNG.execute_lambda( - area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query) + area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False) ) if err != ErrorType.SUCCESS: # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. @@ -285,7 +286,7 @@ async def _local_contents(place) -> dict: ) err, rows = await DB_SESSION_MNG.execute_lambda( area_contents.DBType(), DBWRType.DB_READ.value, - lambda s: DB_SESSION_MNG.execute(s, shared_q), + lambda s: DB_SESSION_MNG.execute(s, shared_q, raise_error=False), ) if err != ErrorType.SUCCESS: LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}") @@ -361,7 +362,7 @@ async def _site_places(place_id) -> dict: .limit(1) ) err, rows = await DB_SESSION_MNG.execute_lambda( - site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q) + site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q, raise_error=False) ) if err != ErrorType.SUCCESS or not rows: return {} @@ -370,14 +371,15 @@ async def _site_places(place_id) -> dict: def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]: - """행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다. + """행 → 스냅샷 항목. 맛집 외 종류별 상한은 들어온 순서(정렬)대로 자른다. seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다.""" out = [] for row in rows: content_type = int(row.content_type) kind = getattr(row, "kind", None) taken = seen.get(content_type, 0) - if taken >= _LOCAL_MAX_PER_TYPE: + # 맛집 수집은 전체 보존인데 여기서 20개로 자르면 발행본만 일부가 사라진다. + if content_type != LocalContentType.RESTAURANT.value and taken >= _LOCAL_MAX_PER_TYPE: continue seen[content_type] = taken + 1 body = row.body if isinstance(row.body, dict) else {} @@ -413,9 +415,16 @@ def _iso(value) -> str | None: async def _select(query) -> list: + """조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게. + + ★ raise_error=False 가 핵심이다 (2026-09-14). 이 함수는 원래 실패를 [] 로 삼키도록 + 썼는데, DB 계층이 기본값 raise_error=True 로 **예외를 던져** 그 처리가 실행될 기회조차 + 없었다. 실측: place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 — + 노래 한 칸이 빠진 화면 대신 아무것도 못 보는 화면이 나갔다. + """ err, rows = await DB_SESSION_MNG.execute_lambda( place_facts.DBType(), DBWRType.DB_READ.value, - lambda s: DB_SESSION_MNG.execute(s, query), + lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False), ) return list(rows) if err == ErrorType.SUCCESS else [] diff --git a/solution/backend/services/social_service.py b/solution/backend/services/social_service.py index 35ccc0c..3a5bffd 100644 --- a/solution/backend/services/social_service.py +++ b/solution/backend/services/social_service.py @@ -19,7 +19,7 @@ from common.database.model.models import ( place_facts, owner_social_accounts as Account, ) -from common.enums import SiteStatus, ErrorType, PUBLISHABLE_FACT_STATUSES +from common.enums import SiteStatus, ErrorType, JobType, PUBLISHABLE_FACT_STATUSES from crud.place_crud import PlaceCRUD from crud import social_crud as db from services import site_payload, social_account_service as accounts @@ -224,7 +224,7 @@ async def create_draft(user_id, place_id, provider=2): await s.flush() row_id = row.post_id if row_id: - await db.enqueue(s, row.post_id, 8) + await db.enqueue(s, row.post_id, JobType.SOCIAL_DRAFT.value) return public_post(row) return await db.transaction(run) diff --git a/solution/backend/services/song_service.py b/solution/backend/services/song_service.py index ba99a3f..52291ad 100644 --- a/solution/backend/services/song_service.py +++ b/solution/backend/services/song_service.py @@ -54,6 +54,7 @@ from crud.song_crud import SongCRUD from services import place_research, site_payload from services.external import gemini_text, suno from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured +from common.job_errors import PermanentJobError _fact_crud = FactCRUD() _place_crud = PlaceCRUD() @@ -65,7 +66,7 @@ _song_crud = SongCRUD() SONGS_DIRNAME = "songs" -class SongAborted(RuntimeError): +class SongAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다.""" @@ -128,12 +129,21 @@ async def run_song(job: dict) -> dict: 발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service).""" payload = job["payload"] - return await ensure_song(payload["place_id"], payload["owner_user_id"]) + # ★ 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석). + return await ensure_song(payload["place_id"], payload["owner_user_id"], force=True) -async def ensure_song(place_id: str, owner_user_id: str) -> dict: +async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) -> dict: """이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다. + ★ **이미 완성된 곡이 있으면 만들지 않는다**(2026-09-15 대표: "재발행할 때 노래 다시 + 생성하면 안 되거든"). 예전에는 `build_service` 가 `publish=True` 마다 이 함수를 불렀고 + 여기에 가드가 없어서, 내용이 하나도 안 바뀐 재발행에도 **Gemini 가사 1회 + Suno 작곡 + 1회**가 그대로 나갔다. 사장님이 발행을 다섯 번 누르면 유료 호출 다섯 번에 + `place_songs` 행이 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로(`SongCRUD.latest_ready`) + 나머지는 값을 만들지 않고 돈만 쓴다. + ★ 곡을 **일부러 다시 만드는 길은 남긴다** — `force=True`. 그 길은 발행이 아니라 + SONG 잡(`run_song`)이고, 사람이 눌러야 돈다. ★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다). 발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다. 음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다.""" @@ -142,6 +152,17 @@ async def ensure_song(place_id: str, owner_user_id: str) -> dict: LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정") return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"} + if not force: + err, ready = await DB_SESSION_MNG.execute_lambda( + place_songs.DBType(), + DBWRType.DB_READ.value, + lambda s: _song_crud.latest_ready(s, uuid.UUID(place_id)), + ) + # `execute()` 는 리스트를 준다(limit 1 이라 0개 아니면 1개다) — None 비교로 보지 않는다. + if err == ErrorType.SUCCESS and ready: + LOG.i(f"[song] place={place_id} 건너뜀 — 이미 곡이 있다(재발행은 다시 만들지 않는다)") + return {"place_id": place_id, "skipped": "이미 곡이 있다"} + err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, diff --git a/solution/backend/services/stay_guide.py b/solution/backend/services/stay_guide.py new file mode 100644 index 0000000..dd019ea --- /dev/null +++ b/solution/backend/services/stay_guide.py @@ -0,0 +1,77 @@ +"""확정된 NOL 링크의 수집 원문에서 공개 안내 섹션만 전달한다.""" +import re +from urllib.parse import urlsplit + + +def nol_stay_guide(url: str, raw) -> dict: + try: + parsed = urlsplit(url) + except ValueError: + return {} + if parsed.scheme != "https" or parsed.hostname != "nol.yanolja.com": + return {} + if not re.fullmatch(r"/stay/domestic/\d+/?", parsed.path): + return {} + text = raw.get("text") if isinstance(raw, dict) else None + if not isinstance(text, str): + return {} + + labels = {"시설/서비스": "service", "이용 안내": "policy", "예약 공지": "reservation"} + # 수집기가 붙인 경계로만 나눈다. 규정 안의 괄호나 문장은 분리하지 않는다. + sections = re.split(r"(?m)^\[(숙소 소개|시설/서비스|이용 안내|예약 공지)\]\s*\n", text) + guide = {} + for label, body in zip(sections[1::2], sections[2::2]): + if label not in labels: + continue + lines = body.strip().splitlines() + if lines and lines[0].strip() == label: + lines.pop(0) + # 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다. + body = "\n".join(line for line in lines if line.strip() != "전체보기").strip() + if body: + guide[labels[label]] = body + if guide: + guide["fields"] = structured_fields(guide) + return guide + + +def structured_fields(guide: dict) -> list[dict]: + """확실한 표기만 구조화한다. 매칭되지 않은 내용은 안내 원문에 남는다.""" + policy = guide.get("policy", "") + service = guide.get("service", "") + reservation = guide.get("reservation", "") + lines = {line.strip().lstrip("- ") for line in (policy + "\n" + reservation).splitlines()} + facilities = {line.strip() for line in service.splitlines()} + fields = [] + + def add(key, label, value, group="rules", note=None): + fields.append(dict(key=key, label=label, value=value, group=group, + **({"note": note} if note else {}))) + + for token, key, label in (("체크인", "check_in_time", "체크인 시간"), + ("체크아웃", "check_out_time", "체크아웃 시간")): + matches = set(re.findall(rf"{token}\s+([0-2]\d:[0-5]\d)(?!\d)", policy)) + if len(matches) == 1: + value = matches.pop() + if int(value[:2]) < 24: + add(key, label, value) + fees = [re.fullmatch(r"전 연령 동일 1인당\s*([\d,]+)\s*(만)?원", line) for line in lines] + amounts = {int(m[1].replace(",", "")) * (10000 if m[2] else 1) for m in fees if m} + if len(amounts) == 1: + add("extra_person_fee", "인원 추가 요금", f"{amounts.pop():,}원", note="1인당 · 전 연령 동일") + if "반려동물 입실금지" in lines and not any("반려동물 입실가능" == line for line in lines): + add("pet_allowed", "반려동물 동반", "불가") + if "전 구역 금연" in lines: + add("smoking", "흡연", "불가", "facilities", "전 구역 금연") + for token, key, label in (("주차가능", "parking", "주차"), ("와이파이", "wifi", "와이파이"), + ("취사가능", "cooking_allowed", "취사")): + if token in facilities: + restrictions = [line.strip().lstrip("- ") for line in reservation.splitlines() + if "조리금지" in line or "조리 금지" in line] + add(key, label, "가능", "facilities", + "\n".join(restrictions) if key == "cooking_allowed" and restrictions else None) + known = [name for name in ("욕조", "개별 화장실", "주방", "테라스/발코니", "OTT (스트리밍 서비스)", + "다이닝룸", "벽난로", "어메니티", "카페형룸") if name in facilities] + if known: + add("facilities", "부대시설", ", ".join(known), "facilities") + return fields diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index ec4e48a..fdcc82d 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -33,6 +33,7 @@ from crud.local_content_crud import LocalContentCRUD from services.grounding import story as grounding from services.external import tour_api, wikimedia from services.llm import perplexity +from services.local_restaurant_enrichment import enrich_place_restaurants from services.prompts import story as prompts # ★ 다섯을 **순차로** 부른다. 처음엔 동시에 띄웠는데 실측(2026-09-09, 전북 군산시)에서 @@ -63,7 +64,7 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str {"role": "system", "content": prompts.SYSTEM_PROMPT}, {"role": "user", "content": prompts.build_prompt(kind, region_label)}, ], - "max_tokens": perplexity.DEFAULT_MAX_TOKENS, + "max_tokens": _MAX_TOKENS.get(kind, perplexity.DEFAULT_MAX_TOKENS), } try: payload = await perplexity.call(body, client=client) @@ -73,12 +74,21 @@ 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 +# 종류별 응답 토큰 상한. 기본(2048)으로 모자란 종류만 적는다. +# +# ★ 왜 (실측 2026-09-15) `reading` 은 30~34꼭지 × 서너 문장이라 다른 종류의 서너 배다. +# 2048 로 부르면 응답이 **문장 한가운데서 잘려** 오고, 파서는 그걸 "JSON 이 아니다" 로 +# 통째로 버린다 — 여섯 지역 전부 0건이었다. 잘린 것은 재시도해도 같은 자리에서 잘린다. +# ★ 상한만 올린다. 꼭지 수를 줄이면 화면이 매번 5~6개만 뽑는 의미가 없어진다 +# (`site/sections/items/ReadingSection.tsx`). +_MAX_TOKENS = {"reading": 8000} + # 사진을 무엇으로 찾을지. 종류마다 출처가 다르다. # # chronicle · postcard 공공데이터(TourAPI) — 장소·시설 사진. 공공누리 Type1/Type3 만 @@ -278,6 +288,13 @@ async def run_local_sync(job: dict) -> dict: if not synced.result.success: LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}") + # ── 1.5 주변 맛집 보강(Perplexity + 네이버) — 업장마다 ───────────── + # ★ TourAPI 블록 바로 뒤다. 그쪽이 이미 만든 place_area_refs 개수를 기준으로 + # "10건 미만이면 채운다"를 판단하기 때문이다(services/local_restaurant_enrichment.py). + out["restaurant_enrichment"] = await enrich_place_restaurants( + uuid.UUID(str(place_id)), region_label, region_code, + ) + # ── 2. 여행 일정(LLM) — 업장마다 ────────────────────────────────── # ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라 # 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다. diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 766c219..1862ed3 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -18,12 +18,13 @@ from config.server_configs import external_api_config from crud.media_crud import MediaCRUD from crud.place_crud import PlaceCRUD from services.external import gemini +from common.job_errors import PermanentJobError _media_crud = MediaCRUD() _place_crud = PlaceCRUD() -class VisionAborted(RuntimeError): +class VisionAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" diff --git a/solution/backend/services/weather_notes.json b/solution/backend/services/weather_notes.json new file mode 100644 index 0000000..d7f474b --- /dev/null +++ b/solution/backend/services/weather_notes.json @@ -0,0 +1,16 @@ +{ + "sky": { + "맑음": ["맑은 날에는 잠깐 쉬어 가며 오늘 담은 사진을 골라 보세요.", "분주했던 걸음을 멈추고 느긋하게 쉬어 가도 좋겠습니다.", "오늘 기억하고 싶은 장면을 한 줄 남겨 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"], + "구름많음": ["구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "여행 사진을 정리하며 잠깐 쉬어 가세요.", "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "오늘의 기분을 짧은 글로 남겨도 좋겠습니다."], + "흐림": ["흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "잠깐 책을 읽으며 다음 일정을 기다려 보세요.", "바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."], + "비": ["비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.", "서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.", "빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.", "젖은 물건을 정리하고 잠깐 쉬어 가세요.", "바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."], + "눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."] + }, + "temperature": { + "혹서": ["더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.", "한낮 산책은 짧게 하고 충분히 쉬어 가세요.", "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.", "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.", "긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."], + "더움": ["바깥 일정 사이에 실내에서 쉬는 시간을 넣어 보세요.", "외출할 때에는 물을 챙기고 무리한 이동은 줄이세요.", "주변을 둘러보더라도 한 번에 오래 걷지는 마세요.", "방문할 곳의 운영 시간을 먼저 확인하고 이동하세요.", "더위가 느껴지면 일정을 줄이고 쉬어 가세요."], + "선선": ["비나 눈이 없다면 주변을 가볍게 둘러보는 건 어떨까요?", "날씨와 이동 경로를 확인하고 짧은 산책을 계획해 보세요.", "바깥 일정을 잡기 전에 방문할 곳의 운영 여부를 확인하세요.", "편한 신발을 챙기고 자신의 속도로 동네를 둘러보세요.", "비가 오지 않는다면 일정 사이에 잠깐 걸어 보세요."], + "쌀쌀": ["외출할 때에는 걸칠 옷을 챙겨 보세요.", "바깥에 오래 머물기보다 쉬어 갈 곳을 함께 정해 두세요.", "일교차에 대비해 옷차림을 조절하세요.", "이동 경로를 짧게 잡고 실내 일정도 함께 살펴보세요.", "돌아오는 시간의 기온도 확인한 뒤 외출하세요."], + "추움": ["따뜻한 옷을 챙기고 바깥 이동은 짧게 잡아 보세요.", "찬 바람을 피할 수 있는 실내 일정을 살펴보세요.", "출발 전 눈이나 결빙으로 이동이 어려운 곳은 없는지 확인하세요.", "야외에 오래 머무르지 않도록 쉬는 시간을 충분히 두세요.", "방문할 곳의 운영 여부를 확인하고 따뜻하게 준비하세요."] + } +} diff --git a/solution/backend/services/weather_notes.py b/solution/backend/services/weather_notes.py new file mode 100644 index 0000000..47cec5f --- /dev/null +++ b/solution/backend/services/weather_notes.py @@ -0,0 +1,21 @@ +"""날씨 안내 계약. 관측값과 분리하고, 확인되지 않은 시설·영업시간은 쓰지 않는다.""" +import json +from functools import lru_cache +from pathlib import Path + + +@lru_cache(maxsize=1) +def _templates() -> dict: + return json.loads(Path(__file__).with_name("weather_notes.json").read_text(encoding="utf-8")) + + +def weather_notes() -> dict: + templates = _templates() + sky = {key: list(lines) for key, lines in templates["sky"].items()} + temperature = {key: list(lines) for key, lines in templates["temperature"].items()} + return { + "notes": {key: lines[0] for key, lines in sky.items()}, + "tempNotes": {key: lines[0] for key, lines in temperature.items()}, + "noteSets": sky, + "tempNoteSets": temperature, + } diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 844fc4f..a3b9493 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -97,13 +97,17 @@ async def test_unverified_fact_blocks_publish(auth_headers, client, db_engine): """검증: 미검증 fact 가 섞인 채로 빌드한다. 기대결과: ★ 스냅샷에서 전부 걸러져 페이지에 남는 내용이 없다 — NO_UNIQUE_CONTENT 로 막힌다. - (필수 항목 누락은 2026-08-27 부터 막지 않는다. 그래서 여기서 걸리는 사유가 바뀌었다.)""" + (필수 항목 누락은 2026-08-27 부터 막지 않는다. 그래서 여기서 걸리는 사유가 바뀌었다.) + + ★ 미검증값은 공식 API 수집으로 만든다. 크롤링은 2026-09-14 부터 빈 자리에 바로 + 노출값으로 들어가므로 더는 '미검증 fact' 를 만드는 경로가 아니다 — + 그래도 UNVERIFIED 가 페이지에 새어 나가면 안 된다는 것은 그대로다.""" h = await auth_headers("u1") pid = await _place(client, h, "미검증펜션") - # 크롤링으로만 들어온 값 = 후보 상태 + # 공식 API 로 들어온 값 = 후보 상태(UNVERIFIED) for k, v in REQUIRED.items(): await client.post(f"/v1/place/{pid}/fact", headers=h, json={ - "key": k, "value": v, "source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"}) + "key": k, "value": v, "source_type": SourceType.API.value, "source_url": "https://api.test/1"}) job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] await _run() @@ -292,17 +296,17 @@ async def _build(client, h, pid, publish=True): async def test_렌더가_실패하면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): """검증: 렌더러가 페이지를 굽지 못했다고 보고한다. 기대결과: ★ FAILED. 페이지가 없는데 "발행됨"으로 남으면 사장님은 404 를 눌러야 안다.""" - from services import render_report + from services import render_service - async def _broken(slug, site_version, timeout_sec, poll_sec=1.0): - return {"schemaVersion": 1, "slug": slug, "siteId": "", "placeId": "", + async def _broken(payload_path, site_version, timeout_sec): + return {"schemaVersion": 1, "slug": "t", "siteId": "", "placeId": "", "siteVersion": site_version, "ok": False, "renderedAt": "2026-01-01T00:00:00Z", "routes": 0, "bundle": "t.js", "uniqueContentCount": 5, "jsonld": [], "mismatches": [], "error": "번들이 없다"} h = await auth_headers("u1") pid = await _built_place(client, h, db_engine) - monkeypatch.setattr(render_report, "wait_for", _broken) + monkeypatch.setattr(render_service, "render_site", _broken) r = await _build(client, h, pid) assert r["build_status"] == "FAILED" @@ -316,10 +320,10 @@ async def test_렌더가_실패하면_발행하지_않는다(auth_headers, clien async def test_구조화데이터가_화면과_다르면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): """검증: 렌더러가 JSON-LD 불일치를 보고한다. 기대결과: ★ JSONLD_MISMATCH — 검색엔진에만 다른 말을 하는 페이지는 나가면 안 된다.""" - from services import render_report + from services import render_service - async def _mismatch(slug, site_version, timeout_sec, poll_sec=1.0): - return {"schemaVersion": 1, "slug": slug, "siteId": "", "placeId": "", + async def _mismatch(payload_path, site_version, timeout_sec): + return {"schemaVersion": 1, "slug": "t", "siteId": "", "placeId": "", "siteVersion": site_version, "ok": False, "renderedAt": "2026-01-01T00:00:00Z", "routes": 0, "bundle": "t.js", "uniqueContentCount": 5, "jsonld": [], "mismatches": ["telephone: '02-0000-0000' 이 화면에 없다"], @@ -327,7 +331,7 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다( h = await auth_headers("u1") pid = await _built_place(client, h, db_engine) - monkeypatch.setattr(render_report, "wait_for", _mismatch) + monkeypatch.setattr(render_service, "render_site", _mismatch) r = await _build(client, h, pid) assert r["build_status"] == "FAILED" @@ -339,14 +343,14 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다( async def test_렌더_결과가_안_오면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): """검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃). 기대결과: ★ FAILED. 확인하지 못한 것을 발행됨으로 남기지 않는다.""" - from services import render_report + from services import render_service - async def _timeout(slug, site_version, timeout_sec, poll_sec=1.0): - return None + async def _timeout(payload_path, site_version, timeout_sec): + raise render_service.RenderFailed("렌더 결과를 받지 못했다 — 렌더러가 도는지 확인이 필요하다") h = await auth_headers("u1") pid = await _built_place(client, h, db_engine) - monkeypatch.setattr(render_report, "wait_for", _timeout) + monkeypatch.setattr(render_service, "render_site", _timeout) r = await _build(client, h, pid) assert r["build_status"] == "FAILED" diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 204d021..f3e3410 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -45,9 +45,14 @@ async def _run_worker(job_id=None): assert ran, "워커가 집을 잡이 없다" -async def test_pipeline_stores_facts_as_candidates(auth_headers, client): +async def test_pipeline_publishes_collected_facts(auth_headers, client): """검증: 수집 잡을 끝까지 돌린다. - 기대결과: fact 가 쌓이되 **전부 후보(UNVERIFIED)** — ★ 크롤링 값은 사이트에 안 나간다.""" + 기대결과: 빈 자리에 들어온 수집값이 **바로 노출값**이 된다(2026-09-14 결정). + + ★ 예전에는 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 '확인된 사실 0건' 으로 + 막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다. + ★ 사람이 넣은 값·정정본을 덮지 않는다는 보호는 그대로다 — + test_recollect_cannot_overwrite_corrected_value 가 그 자리를 지킨다.""" h = await auth_headers("u1") pid = await _ready_place(client, h) @@ -60,8 +65,11 @@ async def test_pipeline_stores_facts_as_candidates(auth_headers, client): listed = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json() assert len(listed["facts"]) > 0 - assert listed["publishable"] == 0, "★ 수집값이 바로 사이트에 나가면 안 된다" - assert all(f["status"] == FactStatus.UNVERIFIED.value for f in listed["facts"]) + assert listed["publishable"] > 0, "수집값이 노출값 자리에 들어가지 않았다" + assert all( + f["status"] in (FactStatus.VERIFIED.value, FactStatus.UNVERIFIED.value) + for f in listed["facts"] + ) async def test_pipeline_records_source_on_every_fact(auth_headers, client): @@ -172,24 +180,26 @@ async def test_coverage_reports_missing_required_fields(auth_headers, client): async def test_recollect_does_not_touch_verified_value(auth_headers, client): - """검증: 수집값을 사람이 승인한 뒤 다시 수집한다. - 기대결과: 값이 같으므로 REFRESHED — ★ 사이트에 나가던 사실이 사라지지 않는다.""" + """검증: 수집을 두 번 돌린다(같은 목데이터라 값이 같다). + 기대결과: 값이 같으므로 REFRESHED — ★ 사이트에 나가던 사실이 사라지지 않고 늘지도 않는다. + + ★ 재수집이 같은 값을 후보로 또 쌓으면 확인 큐가 중복으로 넘치고, 노출값을 지웠다 + 다시 넣으면 그 사이에 사이트에서 사실이 사라진다. 둘 다 안 일어나야 한다.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k6") await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) await _run_worker() - facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()["facts"] - target = next(f for f in facts if f["key"] == "check_in_time") - await client.post(f"/v1/place/{pid}/fact/{target['fact_id']}/transition", headers=h, - json={"status": FactStatus.VERIFIED.value}) - assert (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()["publishable"] == 1 + before = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json() + assert before["publishable"] > 0 + check_in_before = [f for f in before["facts"] if f["key"] == "check_in_time"] + assert len(check_in_before) == 1 and check_in_before[0]["status"] == FactStatus.VERIFIED.value await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) await _run_worker() after = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json() - assert after["publishable"] == 1, "★ 재수집이 확인된 값을 밀어냈다" + assert after["publishable"] == before["publishable"], "★ 재수집이 노출값 수를 바꿨다" check_in = [f for f in after["facts"] if f["key"] == "check_in_time"] assert len(check_in) == 1 and check_in[0]["status"] == FactStatus.VERIFIED.value diff --git a/solution/backend/tests/test_collector.py b/solution/backend/tests/test_collector.py index bb5fb04..d7f775d 100644 --- a/solution/backend/tests/test_collector.py +++ b/solution/backend/tests/test_collector.py @@ -32,6 +32,8 @@ from services.collector.registry import REGISTRY # 여기 남은 것은 **수집 불가 결론이 난 곳**이다(docs/DATA_SOURCE_RESEARCH.md): # 야놀자·여기어때는 403 + Cloudflare 로 막혀 있고 민사 10억 선례가 있다. # 카카오맵은 내부 API 406. 인스타는 Graph API(사장님 OAuth)로만 간다. +# ★ NOL 전용 어댑터(2026-09-14)가 받는 것은 `nol.yanolja.com/stay/domestic/<id>` 한 패턴뿐이다. +# 아래 야놀자 주소가 여전히 막혀야 전용 경로를 연 것과 범용 수집을 푼 것이 갈린다. _REAL_URLS = ( "https://www.yanolja.com/pension/1000", "https://www.goodchoice.kr/product/detail/1000", @@ -174,7 +176,7 @@ def test_registers_only_reviewed_adapters(): 기대결과: 검토를 거쳐 명시적으로 승인한 것만 있다. 이 목록이 늘어나는 것은 **의도된 결정이어야** 하므로, 코드가 몰래 늘어나면 여기서 깨진다.""" - assert adapter_ids() == ["naver_place", "tour_api", "mock", "static_html"], ( + assert adapter_ids() == ["naver_place", "tour_api", "yanolja", "mock", "static_html"], ( f"예상 밖 어댑터가 등록됐다: {adapter_ids()} — 승인 없이 수집 대상을 늘리지 않는다" ) diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 74f8af1..00f3828 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -52,7 +52,8 @@ async def _place_with_facts(client, h, n_verified=5): async def _faq_rows(db_engine, pid): async with db_engine.begin() as c: return (await c.execute( - text("SELECT question, answer, source_fact_ids, status FROM place_faqs WHERE place_id = :p ORDER BY sort_order"), + text("SELECT question, answer, source_fact_ids, status, generated_by FROM place_faqs " + "WHERE place_id = :p ORDER BY sort_order"), {"p": uuid.UUID(pid)}, )).all() @@ -86,31 +87,123 @@ async def test_copy_generates_intro_and_faq_as_published(auth_headers, client, d assert intro[0]["source_type"] == SourceType.LLM.value -async def test_copy_refuses_without_verified_facts(auth_headers, client, monkeypatch): - """검증: 확인된 fact 가 하나도 없는 사업장에서 생성을 시도한다. - 기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 근거 없이 쓰면 환각이고 유료 호출만 낭비다.""" +async def _must_not_call_llm(*args, **kwargs): + raise AssertionError("★ 근거 없이 LLM 을 불렀다") + + +async def test_resume_reuses_completed_job(auth_headers, client, db_engine, monkeypatch): + _patch(monkeypatch, _copy()) + h = await auth_headers("resume-owner") + pid = await _place_with_facts(client, h) + path = f"/v1/place/{pid}/copy" + first = (await client.post(path, headers=h, json={})).json() + pending = (await client.post(path, headers=h, json={"resume": True})).json() + assert pending["job_id"] == first["job_id"] + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + resumed = (await client.post(path, headers=h, json={"resume": True})).json() + assert resumed["job_id"] == first["job_id"] + assert resumed["status"] == JobStatus.DONE.value + assert resumed["created"] is False + job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"] + assert job["place_id"] == pid + assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [ + ("prepare", "done"), ("generate", "done"), ("save", "done"), ("faq_fill", "done"), + ] + fresh = (await client.post(path, headers=h, json={})).json() + assert fresh["job_id"] != first["job_id"] + + +async def test_copy_progress_is_visible_during_generation(auth_headers, client, db_engine, monkeypatch): + h = await auth_headers("progress-owner") + pid = await _place_with_facts(client, h) + _patch(monkeypatch, _copy()) + started = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json() + + async def generate(*args, **kwargs): + row = await JobQueue().get(started["job_id"]) + assert row["status"] == JobStatus.RUNNING.value + assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending"] + return _copy() + + monkeypatch.setattr(gemini_text, "generate_copy", generate) + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + row = await JobQueue().get(started["job_id"]) + assert row["status"] == JobStatus.DONE.value, row["last_error"] + + +async def test_copy_job_cannot_be_read_by_another_owner(auth_headers, client, monkeypatch): + _patch(monkeypatch, _copy()) + owner = await auth_headers("owner") + other = await auth_headers("other") + pid = await _place_with_facts(client, owner) + started = (await client.post(f"/v1/place/{pid}/copy", headers=owner, json={})).json() + response = (await client.get(f"/v1/job/{started['job_id']}", headers=other)).json() + assert response["result"]["success"] is False + assert not response.get("job") + + +async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, monkeypatch): + """검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다. + 기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 쓸 근거도, 채울 공통 질문도 없다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) h = await auth_headers("u1") - 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": "빈주소"}) + 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": "빈호텔주소", "category_name": "호텔"}) r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value -async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, monkeypatch): - """검증: 크롤링으로 들어온 미검증 fact 만 있는 사업장. - 기대결과: 근거로 안 쳐서 FAQ_UNGROUNDED — 미검증 값으로 쓴 문장도 미검증이다.""" +async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, db_engine, monkeypatch): + """검증: 크롤링으로 들어온 미검증 fact 만 있는 펜션. + 기대결과: ★ 근거 0건으로 친다 — LLM 을 부르지 않는다(미검증 값으로 쓴 문장도 미검증이다). + FAQ 는 문의 안내로 20개를 채우고, 미검증인 체크인도 '답이 있는 주제' 로 치지 않는다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) + monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) h = await auth_headers("u1") 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": "미검증주소"}) await client.post(f"/v1/place/{pid}/fact", headers=h, json={ "key": "check_in_time", "value": "15:00", "source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"}) + # CRAWL 은 이제 즉시 노출된다. 이 테스트는 과거에 남은 미검증 행을 명시적으로 준비한다. + async with db_engine.begin() as connection: + await connection.execute(text("UPDATE place_facts SET status = :s WHERE place_id = :p"), + {"s": FactStatus.UNVERIFIED.value, "p": uuid.UUID(pid)}) - r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) - assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value + body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json() + assert body["result"]["success"] is True and body["grounded_facts"] == 0 + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faqs"] == 0 and job["result"]["faq_fill"] == 20 + assert job["progress"]["steps"][1]["status"] == "skipped" + assert job["progress"]["steps"][1]["reason"] == "no_facts" + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 and all(r[4] == SourceType.TEMPLATE.value for r in live) + assert any("체크인" in r[0] for r in live) + + +async def test_zero_facts_still_fill_twenty_without_api_key(auth_headers, client, db_engine, monkeypatch): + """검증: fact 0건 · GEMINI 키 없음인 펜션(분류 없음 — 스테이머뭄처럼). + 기대결과: ★ 잡이 만들어지고 FAQ 가 정확히 20건 — 채우기는 LLM 을 안 쓰므로 키가 필요 없다.""" + monkeypatch.setattr(gemini_text, "is_configured", lambda: False) + monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) + h = await auth_headers("u1") + 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": "무근거주소"}) + + body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json() + assert body["result"]["success"] is True, body["result"] + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faq_fill"] == 20 + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 and len({r[0] for r in live}) == 20 async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, monkeypatch): @@ -128,7 +221,10 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] assert job["result"]["faqs"] == 1 assert any("수영장" in str(r) for r in job["result"]["rejected"]) - assert len(await _faq_rows(db_engine, pid)) == 1 + rows = await _faq_rows(db_engine, pid) + assert len([r for r in rows if r[4] == SourceType.LLM.value]) == 1 + # 채우기가 넣은 문의 안내에도 '수영장 있나요?' 의 거짓 답("네 있습니다")은 없다. + assert not any("있습니다" in r[1] for r in rows if r[4] == SourceType.TEMPLATE.value) async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch): @@ -161,9 +257,10 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, # 사장님이 문구를 고쳐 승인 — 잠긴 상태(CORRECTED)가 되고 책임 주체가 사람으로 넘어온다. async with db_engine.begin() as c: await c.execute( - text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q WHERE place_id = :p"), + text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q " + "WHERE place_id = :p AND generated_by = :llm"), {"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value, - "q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid)}, + "q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid), "llm": SourceType.LLM.value}, ) _patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])])) @@ -177,6 +274,55 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, assert any(r[0] == "새 질문?" and r[3] == FactStatus.VERIFIED.value for r in rows) +_PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) + + +async def test_copy_fills_faqs_to_target_without_duplicates(auth_headers, client, db_engine, monkeypatch): + """검증: 생성된 FAQ 가 1건뿐이고, 사장님이 직접 쓴 FAQ 가 1건 있다. + 기대결과: 노출 중 FAQ 가 정확히 20건 — + · 모자란 만큼은 문의 안내(TEMPLATE)로 채운다 + · fact 로 답할 수 있는 주제(체크인·체크아웃·취소·취사·반려동물)에는 문의 안내를 붙이지 않는다 + · 사장님이 이미 쓴 주제(보증금)도 다시 넣지 않는다""" + _patch(monkeypatch, _copy()) + h = await auth_headers("u1") + pid = await _place_with_facts(client, h) + owner = await client.post(f"/v1/place/{pid}/faq", headers=h, json={"question": "보증금이 있나요?", "answer": "없습니다."}) + assert owner.json()["result"]["success"] is True + + job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"] + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faqs"] == 1 + assert job["result"]["faq_fill"] == 18 + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 + assert len({r[0] for r in live}) == 20, "같은 질문이 두 번 들어갔다" + + fills = [r for r in live if r[4] == SourceType.TEMPLATE.value] + assert len(fills) == 18 + fill_text = " ".join(r[0] for r in fills) + for topic in ("체크인", "체크아웃", "취소", "요리", "반려동물", "보증금"): + assert topic not in fill_text, f"★ 이미 답이 있는 주제 '{topic}' 에 문의 안내가 붙었다" + assert all("문의" in r[1] or "확인" in r[1] for r in fills), "문의 안내가 아닌 답이 섞였다" + + +async def test_regeneration_replaces_fill_faqs(auth_headers, client, db_engine, monkeypatch): + """검증: 생성을 두 번 돌린다. + 기대결과: 노출 중 FAQ 는 여전히 20건 — 옛 문의 안내는 내려가고 다시 고른다(쌓이지 않는다).""" + _patch(monkeypatch, _copy()) + h = await auth_headers("u1") + pid = await _place_with_facts(client, h) + for _ in range(2): + await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 + assert len({r[0] for r in live}) == 20 + + async def test_copy_requires_api_key(auth_headers, client, monkeypatch): """검증: GEMINI_API_KEY 없이 생성을 시도한다. 기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다.""" diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 7e73cc8..1365bf6 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -7,6 +7,7 @@ 수정 : 사람이 직접 넣으면 즉시 노출값 교체 + 재빌드 대상 표시 """ from common.enums import ErrorType, FactStatus, FactWriteOutcome, PlaceCategory, SourceType +from services.external import gemini_text as gt OTA = "https://ota.test/room/1" @@ -125,27 +126,38 @@ async def test_llm_cannot_overwrite_corrected_sentence(auth_headers, client): # ── 생성 프로세스 ───────────────────────────────────────────────────────── -async def test_crawled_fact_starts_as_candidate_and_is_not_published(auth_headers, client): +async def test_crawled_fact_publishes_immediately(auth_headers, client): """검증: 크롤링으로 처음 들어온 값. - 기대결과: UNVERIFIED 후보로 남고 사이트에 안 나간다 — ★ 절대규칙 1.""" + 기대결과: 빈 자리이므로 **바로 노출값**이 된다(2026-09-14 결정). + + ★ 예전에는 UNVERIFIED 후보로 남겼다. 수집이 끝난 뒤에야 오는 값이라 사장님이 승인할 + 화면을 이미 지나가 있었고, 확인된 사실이 하나도 없는 채로 발행 게이트에 걸렸다. + ★ 승인 이력과는 verified_by 로 구별한다 — 자동 노출은 비어 있다. 사람이 본 적 없다는 + 사실 자체를 지우면 나중에 '누가 확인했나' 를 되짚을 수 없다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p1") body = await _crawl(client, h, pid, "check_in_time", "15:00") - assert body["outcome"] == FactWriteOutcome.CANDIDATE_CREATED.value - assert body["fact"]["status"] == FactStatus.UNVERIFIED.value + assert body["outcome"] == FactWriteOutcome.PUBLISHED_CREATED.value + assert body["fact"]["status"] == FactStatus.VERIFIED.value + # 응답에서 None 필드는 지워져 나간다(RemoveNoneResponse) — 없는 것이 곧 비어 있다는 뜻이다. + assert body["fact"].get("verified_by") is None, "★ 자동 노출을 사람 승인으로 위장하면 안 된다" listed = await _facts(client, h, pid) - assert listed["publishable"] == 0 - assert (await _facts(client, h, pid, publishable_only=True)).get("facts", []) == [] + assert listed["publishable"] == 1 + assert (await _facts(client, h, pid, publishable_only=True))["facts"][0]["value"] == "15:00" async def test_approving_candidate_publishes_it(auth_headers, client): """검증: 후보를 VERIFIED 로 승인한다. - 기대결과: verified_at 이 찍히고 사이트에 나갈 수 있게 된다.""" + 기대결과: verified_at 이 찍히고 그 값이 노출값 자리를 가져간다. + + ★ 후보는 사장님이 직접 넣은 값 위에 다른 수집값이 올 때 생긴다 — 크롤링끼리는 + 뒤에 온 값이 앞의 값을 바로 대체하므로(위 테스트) 여기서 사람 입력을 먼저 둔다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p2") - fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] + await _own(client, h, pid, "check_in_time", "15:00") + fid = (await _crawl(client, h, pid, "check_in_time", "16:00"))["fact"]["fact_id"] body = (await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.VERIFIED.value})).json() @@ -157,10 +169,12 @@ async def test_approving_candidate_publishes_it(auth_headers, client): async def test_illegal_transition_is_rejected(auth_headers, client): """검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동. - 기대결과: FACT_INVALID_TRANSITION — 확인을 건너뛴 '정정본'은 만들 수 없다.""" + 기대결과: FACT_INVALID_TRANSITION — 확인을 건너뛴 '정정본'은 만들 수 없다. + + ★ UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다(크롤링은 바로 노출).""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p3") - fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] + fid = (await _crawl(client, h, pid, "check_in_time", "15:00", SourceType.API))["fact"]["fact_id"] r = await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.CORRECTED.value, "value": "16:00"}) @@ -184,12 +198,14 @@ async def test_recrawl_same_value_keeps_verification(auth_headers, client): async def test_recrawl_changed_value_keeps_site_and_queues_candidate(auth_headers, client): - """검증: 확인된 값과 다른 값을 재수집한다(OTA 가 바뀐 경우). - 기대결과: ★ 노출값은 그대로 살아 있고, 새 값은 PENDING_OWNER 후보로만 쌓인다.""" + """검증: 사장님이 넣은 값과 다른 값을 재수집한다(OTA 가 바뀐 경우). + 기대결과: ★ 노출값은 그대로 살아 있고, 새 값은 PENDING_OWNER 후보로만 쌓인다. + + ★ 자동 수집이 사람이 넣은 값을 밀어내지 못한다는 것이 이 테스트의 본체다. + 크롤링이 직전 크롤링 값을 대체하는 것과는 다른 얘기다(위 즉시 노출 결정).""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r2") - fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] - await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.VERIFIED.value}) + await _own(client, h, pid, "check_in_time", "15:00") body = await _crawl(client, h, pid, "check_in_time", "16:00") assert body["outcome"] == FactWriteOutcome.CANDIDATE_CREATED.value @@ -207,8 +223,7 @@ async def test_approving_candidate_replaces_published_value(auth_headers, client 기대결과: 후보가 노출값이 되고 옛 값은 EXPIRED 이력으로 내려간다. 노출값은 여전히 1건.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r3") - old = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] - await client.post(f"/v1/place/{pid}/fact/{old}/transition", headers=h, json={"status": FactStatus.VERIFIED.value}) + await _own(client, h, pid, "check_in_time", "15:00") new = (await _crawl(client, h, pid, "check_in_time", "16:00"))["fact"]["fact_id"] body = (await client.post(f"/v1/place/{pid}/fact/{new}/transition", headers=h, @@ -226,8 +241,7 @@ async def test_repeated_recrawl_does_not_pile_up_candidates(auth_headers, client 기대결과: 후보가 쌓이지 않고 하나가 갱신된다(사람 확인 큐가 중복으로 넘치지 않게).""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r4") - fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] - await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.VERIFIED.value}) + await _own(client, h, pid, "check_in_time", "15:00") await _crawl(client, h, pid, "check_in_time", "16:00") body = await _crawl(client, h, pid, "check_in_time", "17:00") @@ -302,13 +316,18 @@ async def test_publishing_marks_place_for_rebuild(auth_headers, client): async def test_crawl_only_does_not_mark_rebuild(auth_headers, client): """검증: 크롤링이 후보만 쌓았을 때 재빌드 표시. - 기대결과: 안 찍힌다 — 사이트에 나가는 내용이 안 바뀌었으니 재빌드가 필요 없다.""" + 기대결과: 안 찍힌다 — 사이트에 나가는 내용이 안 바뀌었으니 재빌드가 필요 없다. + + ★ 노출값을 바꾼 수집은 반대로 찍혀야 한다(그게 needs_rebuild 의 뜻이다). + 그래서 사장님 값 위에 올라온 **후보** 경로로 확인한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="o4") + await _own(client, h, pid, "check_in_time", "15:00") + before = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"].get("content_updated_at") - await _crawl(client, h, pid, "check_in_time", "15:00") + await _crawl(client, h, pid, "check_in_time", "16:00") place = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"] - assert place.get("content_updated_at") is None + assert place.get("content_updated_at") == before, "후보만 쌓였는데 재빌드 대상이 됐다" async def test_facts_are_scoped_to_owner(auth_headers, client): @@ -320,3 +339,60 @@ async def test_facts_are_scoped_to_owner(auth_headers, client): h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value + + +# ── 캔버스 미리보기 요약 ──────────────────────────────────────────────────── +async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypatch): + """검증: intro 가 요약 임계치(200자)를 넘는다. + 기대결과: /fact/list 응답의 summary 에 축약문이 실리고, value(원문)는 그대로 남는다.""" + async def _fake_summarize(text, **_kwargs): + return "축약된 소개문입니다." + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum1") + long_intro = "조용한 숙소입니다. " * 30 # 200자 초과 + await _crawl(client, h, pid, "intro", long_intro, SourceType.LLM) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "intro") + assert fact["value"] == long_intro + assert fact["summary"] == "축약된 소개문입니다." + + +async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch): + """검증: intro 가 임계치보다 짧다. + 기대결과: summary 가 비어 있고, 요약 API 는 아예 불리지 않는다.""" + calls = [] + async def _fake_summarize(text, **_kwargs): + calls.append(text) + return "호출되면 안 된다" + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum2") + await _crawl(client, h, pid, "intro", "조용한 숙소입니다.", SourceType.LLM) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "intro") + assert fact.get("summary") is None + assert calls == [], "짧은 문장인데 요약 API 를 불렀다" + + +async def test_non_intro_fact_never_gets_summary(auth_headers, client, monkeypatch): + """검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy). + 기대결과: 대상 key 가 아니므로 summary 를 만들지 않는다.""" + calls = [] + async def _fake_summarize(text, **_kwargs): + calls.append(text) + return "호출되면 안 된다" + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum3") + await _own(client, h, pid, "cancel_policy", "환불 규정 안내입니다. " * 30) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "cancel_policy") + assert fact.get("summary") is None + assert calls == [] diff --git a/solution/backend/tests/test_faq_fill.py b/solution/backend/tests/test_faq_fill.py new file mode 100644 index 0000000..db0a464 --- /dev/null +++ b/solution/backend/tests/test_faq_fill.py @@ -0,0 +1,104 @@ +"""FAQ 목표 수 채우기 — ★ 공통 답은 문의 안내뿐이고, 이미 다룬 주제는 다시 넣지 않는다. + +이 기능이 절대 하면 안 되는 것: + - 공통 답에 값·가능 여부를 적는 것 (가게마다 다르다 — 틀리면 예약 클레임) + - fact 로 답할 수 있는 질문에 "문의 부탁드립니다" 를 붙이는 것 (아는 것을 숨긴다) + - 기존 FAQ 와 같은 주제를 다시 넣는 것 +""" +import re + +from common.enums import ErrorType, PlaceCategory +from common.faq_catalog import find_catalog +from services import faq_fill +from services.faq_fill import ExistingFaq + + +def _pension(): + catalog = find_catalog(PlaceCategory.LODGING.value, "펜션") + assert catalog is not None + return catalog + + +def _ids(picks): + return {p.catalog_id for p in picks} + + +def test_pension_catalog_has_thirty_questions(): + """검증: 카탈로그 로드. fact_keys 가 업종 스키마에 없으면 로더가 예외를 던진다.""" + catalog = _pension() + assert len(catalog.items) == 30 + assert len({item.id for item in catalog.items}) == 30 + + +def test_catalog_scope(): + """검증: 호텔·카페에는 펜션 질문을 붙이지 않는다. 분류가 비어 있는 숙박업(스테이머뭄)에는 붙인다.""" + assert find_catalog(PlaceCategory.LODGING.value, "호텔") is None + assert find_catalog(PlaceCategory.CAFE.value, None) is None + assert find_catalog(PlaceCategory.LODGING.value, None) is not None + + +def test_fills_only_the_shortfall(): + """검증: 기존 FAQ 5건(카탈로그와 무관한 질문). 기대결과: 15건만 고른다. 20건이면 0건.""" + catalog = _pension() + picks = faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"기타 질문 {i}") for i in range(5)], set()) + assert len(picks) == 15 and len(_ids(picks)) == 15 + assert faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"질문 {i}") for i in range(20)], set()) == [] + + +def test_skips_topics_answerable_by_facts(): + """검증: 체크인·반려동물 fact 가 있다(객실 fact 는 'A동:max_capacity' 로 적힌다). + 기대결과: 그 질문들은 문의 안내로 채우지 않는다 — LLM 이 fact 로 답할 자리다.""" + picks = faq_fill.pick_fill_faqs(_pension(), [], {"check_in_time", "pet_allowed", "A동:max_capacity"}, target=30) + assert not _ids(picks) & {"check_in", "pet", "capacity"} + + +def test_skips_topics_already_asked_by_keyword(): + """검증: 사장님이 근거 key 없이 쓴 FAQ 가 있다. 기대결과: 질문 낱말로 같은 주제를 알아본다.""" + existing = [ExistingFaq("반려견 데려가도 되나요?"), ExistingFaq("주차 되나요?")] + picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30) + assert not _ids(picks) & {"pet", "parking"} + + +def test_skips_topics_covered_by_source_fact_keys(): + """검증: LLM FAQ 의 질문에는 키워드가 없지만 근거 key 가 [wifi, A동:max_capacity] 다. + 기대결과: 와이파이·인원 질문을 넣지 않는다 — LLM 은 두 주제를 한 문항에 묶기도 한다.""" + existing = [ExistingFaq("편의 안내가 궁금해요", ["wifi", "A동:max_capacity"])] + picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30) + assert not _ids(picks) & {"wifi", "capacity"} + + +def test_answers_are_inquiry_only(): + """검증: 공통 답 문구. + 기대결과: ★ 연락처가 없으면 숫자가 하나도 없다(가격·시각을 지어내지 않는다). 연락처가 있으면 그 번호만 들어간다.""" + catalog = _pension() + for pick in faq_fill.pick_fill_faqs(catalog, [], set(), target=30): + assert not re.search(r"\d", pick.answer), pick.answer + assert "숙소" in pick.answer + for pick in faq_fill.pick_fill_faqs(catalog, [], set(), phone="010-1234-5678", target=30): + assert "전화(010-1234-5678)로" in pick.answer + assert not re.search(r"\d", pick.answer.replace("010-1234-5678", "")), pick.answer + + +def test_answer_particle_and_rotation(): + """검증: 주제 뒤 조사(은/는)와 문구 돌려쓰기.""" + catalog = _pension() + assert faq_fill.fallback_answer(catalog, 0, "보증금 여부", None).startswith("보증금 여부는 ") + assert faq_fill.fallback_answer(catalog, 0, "주변 편의시설", None).startswith("주변 편의시설은 ") + assert len({faq_fill.fallback_answer(catalog, i, "주제", None) for i in range(3)}) == 3 + + +def test_suggested_questions_are_the_answerable_ones(): + """검증: 프롬프트에 실을 질문 = fact 로 답할 수 있는 카탈로그 질문.""" + questions = faq_fill.suggested_questions(_pension(), {"parking", "check_out_time"}) + assert questions == ["체크아웃은 몇 시까지인가요?", "주차할 수 있나요? 몇 대까지 가능한가요?"] + + +async def test_template_source_cannot_write_fact(auth_headers, client): + """검증: fact 를 TEMPLATE 출처로 쓴다. + 기대결과: INVALID_REQUEST_DATA — 막지 않으면 사람 입력처럼 바로 노출값이 된다(fact_service 규칙 4).""" + h = await auth_headers("u1") + 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": "틀주소"}) + r = await client.post(f"/v1/place/{pid}/fact", headers=h, json={ + "key": "check_in_time", "value": "15:00", "source_type": 5, "source_url": "template:test"}) + assert r.json()["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value diff --git a/solution/backend/tests/test_gemini_text.py b/solution/backend/tests/test_gemini_text.py index 0a1087c..d8d6659 100644 --- a/solution/backend/tests/test_gemini_text.py +++ b/solution/backend/tests/test_gemini_text.py @@ -361,3 +361,64 @@ def test_ground_check_grounds_numbers_from_unit_summaries(): grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}]) ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding) assert ok is True + + +# ── 요약(summarize_text) ────────────────────────────────────────────────── +@pytest.fixture(autouse=True) +def _clear_summary_cache(): + """모듈 전역 캐시가 테스트끼리 새어 들어가지 않게 매번 비운다.""" + gt._SUMMARY_CACHE.clear() + + +async def test_summarize_returns_shortened_text(): + """검증: 긴 문장을 요약 API 로 축약한다. + 기대결과: 응답 텍스트가 그대로 반환되고, 원문이 요청 프롬프트에 실린다.""" + payload = { + "candidates": [{"content": {"parts": [{"text": "짧게 줄인 문장입니다."}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 20}, + } + calls = [] + async with _client(_ok(payload, calls)) as c: + result = await gt.summarize_text("첫 번째 테스트용 원문입니다. " * 20, client=c) + + assert result == "짧게 줄인 문장입니다." + prompt = json.loads(calls[0].content)["contents"][0]["parts"][0]["text"] + assert "첫 번째 테스트용 원문입니다." in prompt + + +async def test_summarize_skips_when_not_configured(): + """검증: GEMINI_API_KEY 미설정. + 기대결과: 호출 자체를 안 하고 None — 캔버스는 원문으로 폴백한다.""" + llm.external_api_config.gemini_api_key = "" + result = await gt.summarize_text("두 번째 테스트용 원문입니다. " * 20) + assert result is None + + +async def test_summarize_caches_repeated_calls(): + """검증: 같은 원문을 두 번 요약 요청한다. + 기대결과: 두 번째는 API 를 다시 부르지 않고 캐시된 값을 돌려준다.""" + payload = {"candidates": [{"content": {"parts": [{"text": "캐시 확인용 요약"}]}, "finishReason": "STOP"}]} + calls = [] + text = "세 번째 테스트용 원문입니다. " * 20 + async with _client(_ok(payload, calls)) as c: + first = await gt.summarize_text(text, client=c) + second = await gt.summarize_text(text, client=c) + + assert first == second == "캐시 확인용 요약" + assert len(calls) == 1, "같은 원문인데 API 를 두 번 불렀다" + + +async def test_summarize_returns_none_on_repeated_failure(): + """검증: 재시도까지 전부 5xx 로 실패한다. + 기대결과: 예외를 올리지 않고 None — 요약 실패가 캔버스를 깨면 안 된다.""" + async with _client(lambda r: httpx.Response(503, text="unavailable")) as c: + result = await gt.summarize_text("네 번째 테스트용 원문입니다. " * 20, client=c, max_retries=1) + + assert result is None + + +async def test_summarize_empty_text_returns_none(): + """검증: 빈 문자열. + 기대결과: 호출 없이 None.""" + result = await gt.summarize_text(" ") + assert result is None diff --git a/solution/backend/tests/test_itinerary_llm_service.py b/solution/backend/tests/test_itinerary_llm_service.py index 6463012..0ba01f9 100644 --- a/solution/backend/tests/test_itinerary_llm_service.py +++ b/solution/backend/tests/test_itinerary_llm_service.py @@ -85,13 +85,17 @@ async def _rows(place_id): async def test_generates_both_durations(db_engine, spy_perplexity): """검증: 아무것도 없는 업장. - 기대결과: 기간 둘을 각각 한 번씩 부르고(총 2회) 각각 5개 코스를 저장한다.""" + 기대결과: 기간 둘을 각각 만들고 각각 5개 코스를 저장한다. + + ★ 호출 수는 기간당 MAX_ATTEMPTS 다. 목응답이 5개(< TARGET_COURSES=10)라 서비스가 + 같은 프롬프트로 한 번 더 부르고, 두 번째 응답은 already_seen 에 걸려 전부 버려진다 — + 그래서 호출은 늘어도 저장된 코스는 5개 그대로다.""" calls, _ = spy_perplexity pid = uuid.uuid4() out = await service.ensure_generated(_FakePlace(pid)) - assert len(calls) == 2 + assert len(calls) == 2 * service.MAX_ATTEMPTS assert out["counts"] == {"1박 2일": 5, "2박 3일": 5} rows = await _rows(pid) assert {r.duration for r in rows} == {"1박 2일", "2박 3일"} @@ -132,8 +136,8 @@ async def test_fills_only_the_missing_duration(db_engine, spy_perplexity): out = await service.ensure_generated(_FakePlace(pid)) - assert len(calls) == 1 - assert "2박 3일" in calls[0]["messages"][1]["content"] + assert len(calls) == service.MAX_ATTEMPTS + assert all("2박 3일" in c["messages"][1]["content"] for c in calls) assert out["counts"] == {"2박 3일": 5} @@ -237,7 +241,7 @@ async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, out = await service.ensure_generated_by_id(pid) - assert len(calls) == 2 + assert len(calls) == 2 * service.MAX_ATTEMPTS assert out["counts"] == {"1박 2일": 5, "2박 3일": 5} diff --git a/solution/backend/tests/test_job_queue.py b/solution/backend/tests/test_job_queue.py index ac3adde..7678d6c 100644 --- a/solution/backend/tests/test_job_queue.py +++ b/solution/backend/tests/test_job_queue.py @@ -33,6 +33,32 @@ async def test_enqueue_and_claim(db_engine): assert row["status"] == JobStatus.RUNNING.value +async def test_progress_rejects_old_worker_and_resets_on_retry(db_engine): + from services.job_progress import JobProgress + + q = JobQueue() + jid = await q.enqueue(JobType.COPY.value, {}) + old = await q.claim("w1") + progress = JobProgress(old, ("prepare", "generate")) + async with progress.step("prepare"): + pass + try: + async with progress.step("generate"): + raise ValueError("upstream failed") + except ValueError: + pass + row = await q.get(jid) + assert row["progress"]["steps"][1]["status"] == "failed" + await q.fail(jid, "w1", "upstream failed", backoff_sec=0) + new = await q.claim("w2") + assert not await q.set_progress(old, {"steps": [], "attempt": 1}) + retried = JobProgress(new, ("prepare", "generate")) + async with retried.step("prepare"): + row = await q.get(jid) + assert row["progress"]["attempt"] == 2 + assert [s["status"] for s in row["progress"]["steps"]] == ["running", "pending"] + + async def test_claim_is_atomic_across_workers(db_engine): """검증: 잡 1건에 워커 5개가 동시에 달려든다. 기대결과: 정확히 1명만 가져간다 — 도커에서 워커를 몇 개로 스케일하든 이중 실행이 없다.""" diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py index fb98581..81ea445 100644 --- a/solution/backend/tests/test_place_search.py +++ b/solution/backend/tests/test_place_search.py @@ -105,16 +105,38 @@ async def test_search_rejects_short_query(client): async def test_rate_limit_closes_the_tap(client, monkeypatch): - """인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다.""" + """인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다. + + ★ 검색어를 매번 바꾼다. 같은 검색어는 ttl_cache 가 레이트리밋보다 **앞에서** 받아 + 외부 API 를 아예 안 부르므로(요금이 안 나가므로 제한할 이유도 없다), 같은 말을 + 반복하면 이 가드가 아니라 캐시를 시험하게 된다.""" rate_limit.reset() _patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")]) from services import place_service monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2) - for _ in range(2): - assert (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["result"]["success"] is True + for i in range(2): + r = (await client.get("/v1/place/search", params={"q": f"하조대펜션{i}"})).json() + assert r["result"]["success"] is True - blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json() + blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션9"})).json() assert blocked["result"]["success"] is False assert blocked["result"]["code"] == ErrorType.HTTP_TO_MANY_REQUEST.value + + +async def test_cached_query_does_not_spend_the_rate_limit(client, monkeypatch): + """검증: 같은 검색어를 제한 횟수보다 많이 친다. + 기대결과: 통과한다 — 캐시가 받으면 외부 API 를 안 부르고, 안 부르면 요금도 안 난다. + + ★ 레이트리밋을 캐시보다 앞으로 옮기면 사장님이 상호를 고쳐 가며 치는 평범한 사용이 + 막힌다. 이 순서가 의도라는 것을 여기서 못 박는다.""" + rate_limit.reset() + _patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")]) + + from services import place_service + + monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2) + for _ in range(5): + r = (await client.get("/v1/place/search", params={"q": "같은검색어"})).json() + assert r["result"]["success"] is True diff --git a/solution/backend/tests/test_render_process.py b/solution/backend/tests/test_render_process.py new file mode 100644 index 0000000..f4f28d0 --- /dev/null +++ b/solution/backend/tests/test_render_process.py @@ -0,0 +1,38 @@ +import asyncio +import sys + +import pytest + +from services import render_service + + +@pytest.fixture(autouse=True) +def fake_renderer(monkeypatch, tmp_path): + # 이 파일은 conftest의 렌더 대역 대신 실제 자식 프로세스 수명만 검사한다. + monkeypatch.setenv("SITE_OUTPUT_DIR", str(tmp_path)) + + +async def test_process_success(): + code, _ = await render_service._run_subprocess([sys.executable, "-c", "print(1)"], 2) + assert code == 0 + + +async def test_timeout_releases_lock(): + code, _ = await render_service._run_subprocess( + [sys.executable, "-c", "import time;time.sleep(5)"], 0.1 + ) + assert code is None + code, _ = await render_service._run_subprocess([sys.executable, "-c", "print(1)"], 2) + assert code == 0 + + +async def test_cancel_releases_lock(): + task = asyncio.create_task(render_service._run_subprocess( + [sys.executable, "-c", "import time;time.sleep(5)"], 10 + )) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + code, _ = await render_service._run_subprocess([sys.executable, "-c", "print(1)"], 2) + assert code == 0 diff --git a/solution/backend/tests/test_rollback.py b/solution/backend/tests/test_rollback.py new file mode 100644 index 0000000..0056152 --- /dev/null +++ b/solution/backend/tests/test_rollback.py @@ -0,0 +1,131 @@ +"""롤백 — 예전 버전으로 공개 주소를 되돌린다. + +★ 이 경로가 절대 하면 안 되는 것: + - 재수집·재생성으로 스냅샷을 새로 만드는 것 (그 버전이 발행됐던 그대로여야 한다) + - 실패했는데 site.current_version_id 를 바꾸는 것 (직전 공개본이 그대로 있어야 한다) + - 지금 공개 중인 버전으로 "롤백"을 허용하는 것 (의미 없는 잡을 만든다) +""" +import uuid + +from common.enums import BuildStatus, JobStatus, SiteStatus +from crud.job_crud import JobQueue +from worker.handlers import build_handler +from worker.runner import Worker + +from tests.test_build_publish import _approved_media, _place, _verified_facts + + +async def _run(): + assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True + + +async def _publish(client, h, pid, intro="하조대 해변 도보 3분 거리의 펜션입니다."): + await _verified_facts(client, h, pid, {"intro": intro}) + job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] + await _run() + return (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"] + + +async def test_rollback_to_earlier_version(auth_headers, client, db_engine): + """검증: v1 발행 → v2 발행 → v1 로 롤백. + 기대결과: 공개 버전이 다시 v1 이 된다. 롤백은 새 site_versions 행을 만들지 않는다.""" + h = await auth_headers("u1") + pid = await _place(client, h) + await _approved_media(db_engine, pid) + + r1 = await _publish(client, h, pid, "v1 소개 — 하조대 해변 도보 3분 거리의 펜션입니다.") + assert r1["published"] is True + r2 = await _publish(client, h, pid, "v2 소개 — 리모델링을 마친 하조대 앞 오션뷰 펜션입니다.") + assert r2["published"] is True + assert r2["version"] == r1["version"] + 1 + + job_id = (await client.post( + f"/v1/place/{pid}/site/version/rollback", headers=h, json={"target_version": r1["version"]} + )).json()["job_id"] + await _run() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + result = job["result"] + assert result["rolled_back"] is True, result.get("error") + assert result["target_version"] == r1["version"] + + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site["site"]["status"] == SiteStatus.PUBLISHED.value + assert site["current_version"]["version"] == r1["version"] + assert site["current_version"]["build_status"] == BuildStatus.BUILT.value + + versions = (await client.get(f"/v1/place/{pid}/site/version/list", headers=h)).json()["versions"] + # ★ 롤백은 새 버전을 만들지 않는다 — v1·v2 두 개뿐이어야 한다. + assert len(versions) == 2 + + +async def test_rollback_rejects_current_version(auth_headers, client, db_engine): + """검증: 지금 공개 중인 버전으로 롤백을 시도한다. + 기대결과: ★ 성공으로 끝나지 않는다(last_error) — 의미 없는 재굽기를 하지 않는다. + (BuildAborted 와 같은 패턴 — 재시도해도 소용없는 중단이라 PENDING(백오프)으로 남고, + 소진되면 DEAD 된다. 여기서 보는 건 "그래도 site 상태는 안 바뀐다"는 것이다.)""" + h = await auth_headers("u1") + pid = await _place(client, h) + await _approved_media(db_engine, pid) + r1 = await _publish(client, h, pid) + + job_id = (await client.post( + f"/v1/place/{pid}/site/version/rollback", headers=h, json={"target_version": r1["version"]} + )).json()["job_id"] + await _run() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] != JobStatus.DONE.value + assert "이미" in (job.get("last_error") or "") + + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site["current_version"]["version"] == r1["version"] + + +async def test_rollback_rejects_unknown_version(auth_headers, client, db_engine): + """검증: 존재하지 않는 버전 번호로 롤백을 시도한다. + 기대결과: ★ 잡이 성공으로 끝나지 않는다 — 공개 상태는 그대로다.""" + h = await auth_headers("u1") + pid = await _place(client, h) + await _approved_media(db_engine, pid) + r1 = await _publish(client, h, pid) + + job_id = (await client.post( + f"/v1/place/{pid}/site/version/rollback", headers=h, json={"target_version": 999} + )).json()["job_id"] + await _run() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] != JobStatus.DONE.value + + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site["site"]["status"] == SiteStatus.PUBLISHED.value + assert site["current_version"]["version"] == r1["version"] + + +async def test_failed_rollback_keeps_current_published_version(auth_headers, client, db_engine, monkeypatch): + """검증: 롤백 대상 렌더가 실패한다(구조화 데이터 불일치로 시뮬레이션). + 기대결과: ★ site.current_version_id 는 바뀌지 않는다 — 직전 공개본이 그대로 서비스된다.""" + from services import render_service + + h = await auth_headers("u1") + pid = await _place(client, h) + await _approved_media(db_engine, pid) + r1 = await _publish(client, h, pid, "v1 소개 — 하조대 해변 도보 3분 거리의 펜션입니다.") + r2 = await _publish(client, h, pid, "v2 소개 — 리모델링을 마친 하조대 앞 오션뷰 펜션입니다.") + site_before = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site_before["current_version"]["version"] == r2["version"] + + async def _broken(payload_path, site_version, timeout_sec): + raise render_service.RenderFailed("렌더러가 죽었다(시뮬레이션)") + + monkeypatch.setattr(render_service, "render_site", _broken) + + job_id = (await client.post( + f"/v1/place/{pid}/site/version/rollback", headers=h, json={"target_version": r1["version"]} + )).json()["job_id"] + await _run() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value + assert job["result"]["rolled_back"] is False + + site_after = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site_after["current_version"]["version"] == r2["version"], "실패했는데 공개 버전이 바뀌었다" diff --git a/solution/backend/tests/test_search_console_alerts.py b/solution/backend/tests/test_search_console_alerts.py new file mode 100644 index 0000000..8040f0e --- /dev/null +++ b/solution/backend/tests/test_search_console_alerts.py @@ -0,0 +1,21 @@ +import httpx +from services import search_console_alerts as alerts + + +async def test_teams_payload_has_required_attachment_fields(monkeypatch): + requests = [] + real_client = httpx.AsyncClient + + def receive(request): + import json + requests.append(json.loads(request.content)) + return httpx.Response(202) + + monkeypatch.setattr(alerts.httpx, "AsyncClient", lambda **kw: real_client( + transport=httpx.MockTransport(receive), **kw, + )) + assert await alerts.send_alert("https://example.test/webhook", "https://example.test/s/a", "test") + card = requests[0]["attachments"][0] + assert card["contentType"] == "application/vnd.microsoft.card.adaptive" + assert "contentUrl" in card and card["contentUrl"] is None + assert card["content"]["$schema"] == "http://adaptivecards.io/schemas/adaptive-card.json" diff --git a/solution/backend/tests/test_search_console_client.py b/solution/backend/tests/test_search_console_client.py new file mode 100644 index 0000000..4eb839f --- /dev/null +++ b/solution/backend/tests/test_search_console_client.py @@ -0,0 +1,342 @@ +"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사. + +★ 실제 Google API 를 호출하지 않는다(httpx.MockTransport). google-auth 도 이 +저장소의 의존성이 아니라서(Codex 가 추가) 인증은 모듈 함수 +`_service_account_credentials` / `_refresh_sync` 를 monkeypatch 해서 흉내 낸다 — +설치 여부와 무관하게 이 테스트가 돌아야 한다. + +여기서 고정하는 것: + 1. `inspect_url` 은 `inspectionResult.indexStatusResult` 만 돌려준다 + 2. ★ 그 필드가 없는 응답을 "미색인"으로 짐작하지 않고 예외를 올린다 + 3. 오류는 전부 `SearchConsoleError(code)` 로 정규화되고, 원본 예외 메시지·응답 + 본문이 `code` 에 섞여 나오지 않는다 + 4. 사이트맵 제출 URL 은 property/sitemap 주소를 완전 percent-encode 한다 +""" +import asyncio + +import httpx +import pytest + +from services import search_console_client as scc +from services.search_console_client import SearchConsoleClient, SearchConsoleError + +PROPERTY = "https://example.com/" +SITEMAP = "https://example.com/sitemap.xml" +PAGE = "https://example.com/s/butter/" + + +def _auth(monkeypatch, *, token="test-token", load_error=None, refresh_error=None): + """`_service_account_credentials`/`_refresh_sync` 를 흉내내 google-auth 없이 인증을 통과시킨다.""" + state = {"loads": 0, "refreshes": 0} + + class _FakeCredentials: + def __init__(self): + self.token = None + self.valid = False + + def fake_load(path): + state["loads"] += 1 + if load_error is not None: + raise load_error + return _FakeCredentials() + + def fake_refresh(creds): + state["refreshes"] += 1 + if refresh_error is not None: + raise refresh_error + creds.token = token + creds.valid = bool(token) + + monkeypatch.setattr(scc, "_service_account_credentials", fake_load) + monkeypatch.setattr(scc, "_refresh_sync", fake_refresh) + return state + + +def _client(handler, monkeypatch, **auth_kwargs) -> SearchConsoleClient: + _auth(monkeypatch, **auth_kwargs) + return SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) + + +# ── 1) 사이트맵 제출 ────────────────────────────────────────────────────── +async def test_사이트맵_제출은_URL을_완전_퍼센트인코딩한다(monkeypatch): + """검증: property/sitemap 주소를 PUT 경로에 싣는다. + 기대결과: 두 주소 모두 `/` `:` 까지 percent-encode 되어 경로 세그먼트 하나로 들어간다.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + captured["url"] = str(request.url) + captured["auth"] = request.headers.get("Authorization") + return httpx.Response(200) + + client = _client(handler, monkeypatch, token="tok-abc") + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert captured["method"] == "PUT" + assert captured["url"] == ( + f"{scc.SITEMAPS_BASE}/https%3A%2F%2Fexample.com%2F" + "/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml" + ) + assert captured["auth"] == "Bearer tok-abc" + + +async def test_사이트맵_제출_성공은_아무것도_돌려주지_않는다(monkeypatch): + """검증: 2xx 응답(빈 본문). + 기대결과: 예외 없이 끝난다 — 반환값은 None.""" + def handler(request): + return httpx.Response(200) + + client = _client(handler, monkeypatch) + async with client: + assert await client.submit_sitemap(PROPERTY, SITEMAP) is None + + +# ── 2) URL 검사 ─────────────────────────────────────────────────────────── +async def test_URL_검사는_indexStatusResult만_돌려준다(monkeypatch): + """검증: 응답에 indexStatusResult 외에 mobileUsabilityResult 등 다른 필드도 있다. + 기대결과: indexStatusResult 만 뽑아 돌려준다.""" + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert scc.INSPECT_URL in str(request.url) + return httpx.Response(200, json={ + "inspectionResult": { + "indexStatusResult": {"verdict": "PASS", "coverageState": "Submitted and indexed"}, + "mobileUsabilityResult": {"verdict": "PASS"}, + } + }) + + client = _client(handler, monkeypatch) + async with client: + result = await client.inspect_url(PROPERTY, PAGE) + + assert result == {"verdict": "PASS", "coverageState": "Submitted and indexed"} + + +async def test_URL_검사_요청_본문은_inspectionUrl과_siteUrl이다(monkeypatch): + """검증: 검사 요청 본문. + 기대결과: {"inspectionUrl": 검사할 페이지, "siteUrl": 프로퍼티} 그대로.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = request.content + return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {"verdict": "PASS"}}}) + + import json as _json + + client = _client(handler, monkeypatch) + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert _json.loads(captured["body"]) == {"inspectionUrl": PAGE, "siteUrl": PROPERTY} + + +async def test_inspectionResult가_없으면_미색인으로_넘겨짚지_않는다(monkeypatch): + """검증: ★ 응답 본문이 비어 있다({}). + 기대결과: SearchConsoleError("missing_inspection_result") — 빈 dict 를 돌려주지 않는다. + (호출측이 빈 dict 를 "미색인"으로 오판할 수 있으므로 여기서 예외로 끊는다.)""" + def handler(request): + return httpx.Response(200, json={}) + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "missing_inspection_result" + + +async def test_indexStatusResult가_없으면_예외(monkeypatch): + """검증: inspectionResult 는 있는데 indexStatusResult 가 없다(다른 결과만 있음). + 기대결과: SearchConsoleError("missing_index_status_result").""" + def handler(request): + return httpx.Response(200, json={"inspectionResult": {"mobileUsabilityResult": {}}}) + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "missing_index_status_result" + + +async def test_indexStatusResult가_빈_객체면_예외(monkeypatch): + """검증: indexStatusResult 는 있지만 빈 dict({}) — 실제 검사가 안 된 응답이다. + 기대결과: SearchConsoleError("missing_index_status_result") — 빈 결과를 색인 상태로 돌려주지 않는다.""" + def handler(request): + return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {}}}) + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "missing_index_status_result" + + +async def test_비정상_JSON_본문은_invalid_json(monkeypatch): + """검증: 200 인데 본문이 JSON 이 아니다. + 기대결과: SearchConsoleError("invalid_json") — 파싱 실패가 조용히 넘어가지 않는다.""" + def handler(request): + return httpx.Response(200, text="<html>not json</html>") + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "invalid_json" + + +# ── 3) HTTP 상태 코드 정규화 ───────────────────────────────────────────── +@pytest.mark.parametrize( + ("status", "code"), + [(401, "unauthorized"), (403, "forbidden"), (429, "rate_limited"), (500, "server_error"), (503, "server_error")], +) +async def test_HTTP_오류_상태코드는_code로_정규화된다(monkeypatch, status, code): + """검증: 401/403/429/5xx 응답. + 기대결과: SearchConsoleError.code 가 상태별로 정규화되고, Google 응답 본문은 code 에 섞이지 않는다.""" + def handler(request): + return httpx.Response(status, text="google 응답 본문(비밀은 아니지만 새면 안 된다)") + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert exc.value.code == code + assert "google 응답 본문" not in str(exc.value) + + +# ── 4) 전송 오류 ───────────────────────────────────────────────────────── +async def test_타임아웃은_timeout으로_정규화(monkeypatch): + """검증: 요청이 타임아웃된다. + 기대결과: SearchConsoleError("timeout").""" + def handler(request): + raise httpx.ReadTimeout("timed out", request=request) + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "timeout" + + +async def test_연결_실패는_transport_error로_정규화(monkeypatch): + """검증: 연결 자체가 끊긴다(DNS 실패 등). + 기대결과: SearchConsoleError("transport_error").""" + def handler(request): + raise httpx.ConnectError("연결 실패", request=request) + + client = _client(handler, monkeypatch) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert exc.value.code == "transport_error" + + +# ── 5) 인증 실패 ───────────────────────────────────────────────────────── +async def test_키파일_로딩_실패는_invalid_credentials_file(monkeypatch): + """검증: 서비스 계정 키 파일을 못 읽는다(없음·손상). + 기대결과: SearchConsoleError("invalid_credentials_file") — 원본 예외 메시지는 안 실린다.""" + def handler(request): + raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") + + client = _client(handler, monkeypatch, load_error=ValueError("키 파일 내용: super-secret-key-material")) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert exc.value.code == "invalid_credentials_file" + assert "super-secret-key-material" not in str(exc.value) + + +async def test_토큰_갱신_실패는_auth_failed(monkeypatch): + """검증: 키 파일은 읽었지만 Google 토큰 발급이 거부된다(폐기된 키 등). + 기대결과: SearchConsoleError("auth_failed") — 원본 예외 메시지는 안 실린다.""" + def handler(request): + raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") + + client = _client(handler, monkeypatch, refresh_error=RuntimeError("invalid_grant: token revoked=xyz")) + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.inspect_url(PROPERTY, PAGE) + + assert exc.value.code == "auth_failed" + assert "revoked=xyz" not in str(exc.value) + + +async def test_토큰이_비면_auth_failed(monkeypatch): + """검증: 갱신은 예외 없이 끝났지만 credentials.token 이 비어 있다. + 기대결과: SearchConsoleError("auth_failed") — 빈 토큰으로 요청을 보내지 않는다.""" + def handler(request): + raise AssertionError("빈 토큰으로 네트워크를 타면 안 된다") + + client = _client(handler, monkeypatch, token="") + with pytest.raises(SearchConsoleError) as exc: + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert exc.value.code == "auth_failed" + + +async def test_인증정보_로딩과_유효한_토큰_갱신은_한_번만_한다(monkeypatch): + """검증: 같은 클라이언트로 두 번 호출한다. + 기대결과: 키 파일 로딩 1회 · 토큰 갱신 1회 — 두 번째 호출은 여전히 유효한(`credentials.valid`) + 토큰을 그대로 재사용한다.""" + def handler(request): + return httpx.Response(200) + + state = _auth(monkeypatch) + client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) + async with client: + await client.submit_sitemap(PROPERTY, SITEMAP) + await client.submit_sitemap(PROPERTY, SITEMAP) + + assert state["loads"] == 1 + assert state["refreshes"] == 1 + + +async def test_동시_호출은_토큰_갱신을_한_번만_한다(monkeypatch): + """검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다. + 기대결과: 갱신 1회 — 잠금 없이 두 번째 요청이 갱신 중인 자격을 다시 갱신하면 + Google 토큰 엔드포인트를 요청 수만큼 때리게 된다.""" + import time as _time + + def handler(request): + return httpx.Response(200) + + state = _auth(monkeypatch) + + def slow_refresh(creds): + _time.sleep(0.05) + state["refreshes"] += 1 + creds.token = "tok" + creds.valid = True + + monkeypatch.setattr(scc, "_refresh_sync", slow_refresh) + + client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler)) + async with client: + await asyncio.gather( + client.submit_sitemap(PROPERTY, SITEMAP), + client.submit_sitemap(PROPERTY, SITEMAP), + ) + + assert state["refreshes"] == 1 + + +# ── 6) 닫기 ────────────────────────────────────────────────────────────── +async def test_async_컨텍스트매니저는_내부_httpx_클라이언트를_닫는다(monkeypatch): + """검증: `async with` 블록을 빠져나간다. + 기대결과: 내부 httpx.AsyncClient 가 닫힌다 — 커넥션을 남겨두지 않는다.""" + def handler(request): + return httpx.Response(200) + + client = _client(handler, monkeypatch) + async with client: + pass + + assert client._client.is_closed is True diff --git a/solution/backend/tests/test_search_console_service.py b/solution/backend/tests/test_search_console_service.py new file mode 100644 index 0000000..401d00b --- /dev/null +++ b/solution/backend/tests/test_search_console_service.py @@ -0,0 +1,282 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.model.models import places, sites, site_versions, site_search_status +from crud import search_console_crud as store +from services import search_console_service as service +from services.search_console_client import SearchConsoleError +from services.search_console_alerts import alert_reason, send_alert +from services.search_console_settings import SearchConsoleSettings, belongs_to_property, load_settings + +NOW = datetime.now(timezone.utc) +SETTINGS = SearchConsoleSettings("https://example.com/", "/test/key.json") + + +def status(**changes): + return SimpleNamespace(site_id=uuid.uuid4(), page_url="https://example.com/s/stay", + property_url=SETTINGS.property_url, published_at=NOW - timedelta(days=8), + sitemap_submitted_at=None, inspected_at=None, inspection=None, + first_indexed_at=None, error_code=None, failures=0, alerted_at=None, + next_check_at=NOW, **changes) + + +@pytest.mark.parametrize("url,prop,expected", [ + ("https://example.com/s/a", "https://example.com/", True), + ("https://example.com.evil.test/s/a", "https://example.com/", False), + ("https://sub.example.com/s/a", "sc-domain:example.com", True), + ("https://notexample.com/s/a", "sc-domain:example.com", False), + ("http://example.com/s/a", "sc-domain:example.com", False), + ("https://example.com/s/a?secret=x", "sc-domain:example.com", False), +]) +def test_property_scope(url, prop, expected): + assert belongs_to_property(url, prop) is expected + + +def test_disabled_needs_no_credentials(monkeypatch): + monkeypatch.delenv("GSC_ENABLED", raising=False) + assert load_settings() is None + monkeypatch.setenv("GSC_ENABLED", "1") + monkeypatch.delenv("GSC_PROPERTY_URL", raising=False) + with pytest.raises(ValueError): + load_settings() + + +async def test_submit_and_inspect_are_separate(monkeypatch): + row = status() + client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"})) + monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={row.page_url})) + monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com") + await service.check_site(client, row, SETTINGS, set()) + assert row.sitemap_submitted_at + assert row.inspected_at + assert row.first_indexed_at is None + assert row.next_check_at > NOW + assert row.error_code is None + + +async def test_missing_sitemap_url_retries_without_google_call(monkeypatch): + row = status() + client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock()) + monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value=set())) + await service.check_site(client, row, SETTINGS, set()) + assert row.error_code == "URL_NOT_IN_SITEMAP" + assert row.sitemap_submitted_at is None + assert row.failures == 1 + client.submit_sitemap.assert_not_awaited() + client.inspect_url.assert_not_awaited() + + +async def test_one_sitemap_submission_per_batch(monkeypatch): + client = SimpleNamespace(submit_sitemap=AsyncMock()) + monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url})) + seen = set() + await service.submit_sitemap(client, status(), seen) + await service.submit_sitemap(client, status(), seen) + client.submit_sitemap.assert_awaited_once() + + +async def test_inspection_failure_preserves_previous_observation(): + row = status() + row.sitemap_submitted_at = NOW + row.inspection = {"verdict": "PASS"} + row.inspected_at = NOW - timedelta(days=1) + client = SimpleNamespace(inspect_url=AsyncMock(side_effect=SearchConsoleError("HTTP_429"))) + await service.check_site(client, row, SETTINGS, set()) + assert row.inspection == {"verdict": "PASS"} + assert row.inspected_at < NOW + assert row.error_code == "HTTP_429" + assert row.failures == 1 + + +def test_first_indexed_is_observation_time_not_google_crawl_time(): + row = status() + service.record_inspection(row, {"verdict": "PASS", "lastCrawlTime": "2020-01-01T00:00:00Z"}, NOW) + service.record_inspection(row, {"verdict": "PASS"}, NOW + timedelta(days=1)) + assert row.first_indexed_at == NOW + + +def test_alert_cooldown_and_backoff(): + row = status() + assert alert_reason(row, NOW, 7) + row.alerted_at = NOW + assert alert_reason(row, NOW, 7) is None + for _ in range(30): + service.record_error(row, "HTTP_503", NOW) + assert row.next_check_at == NOW + timedelta(days=1) + + +async def test_failed_alert_does_not_mark_delivered(monkeypatch): + row = status() + row.sitemap_submitted_at = NOW + client = SimpleNamespace(inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"})) + send = AsyncMock(return_value=False) + monkeypatch.setattr(service, "send_alert", send) + await service.check_site(client, row, SETTINGS, set()) + send.assert_awaited_once() + assert row.alerted_at is None + + +async def test_empty_alert_url_never_calls_network(): + assert not await send_alert("", "https://example.com/s/a", "delayed") + + +async def test_disabled_cron_never_opens_database(monkeypatch): + monkeypatch.delenv("GSC_ENABLED", raising=False) + call = AsyncMock() + monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", call) + await service.run_scheduled_check() + call.assert_not_awaited() + + +async def seed_site(session, *, state=3): + place = places(place_id=uuid.uuid4(), owner_user_id=uuid.uuid4(), name="숙소", category=1) + site = sites(site_id=uuid.uuid4(), place_id=place.place_id, status=state, + domain="stay", published_at=NOW) + version = site_versions(site_version_id=uuid.uuid4(), site_id=site.site_id, version=1, build_status=2) + site.current_version_id = version.site_version_id + session.add_all([place, site, version]) + await session.flush() + return site, place, version + + +async def test_registration_survives_restart_and_resets_on_publish(db_engine, monkeypatch): + monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com") + async with AsyncSession(db_engine, expire_on_commit=False) as session: + site, _, _ = await seed_site(session) + await service.register_publications(session, SETTINGS) + await session.commit() + row = await session.get(site_search_status, site.site_id) + row.sitemap_submitted_at = NOW + row.first_indexed_at = NOW + await session.commit() + await service.register_publications(session, SETTINGS) + assert row.first_indexed_at == NOW + site.published_at = NOW + timedelta(seconds=1) + await session.commit() + await service.register_publications(session, SETTINGS) + await session.commit() + await session.refresh(row) + assert row.first_indexed_at is None + assert row.sitemap_submitted_at is None + + +async def test_unpublished_and_deleted_places_are_not_checked(db_engine, monkeypatch): + monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com") + async with AsyncSession(db_engine) as session: + site, place, _ = await seed_site(session) + await service.register_publications(session, SETTINGS) + assert len(await store.due_sites(session, SETTINGS.property_url)) == 1 + site.status = 5 + await session.flush() + assert not await store.due_sites(session, SETTINGS.property_url) + site.status = 3 + place.deleted = True + await session.flush() + assert not await store.due_sites(session, SETTINGS.property_url) + + +async def test_batch_lock_excludes_second_process(db_engine): + async with AsyncSession(db_engine) as first, AsyncSession(db_engine) as second: + assert await store.lock_batch(first) + assert not await store.lock_batch(second) + await first.rollback() + assert await store.lock_batch(second) + + +async def test_full_batch_commits_and_does_not_repeat_same_day(db_engine, monkeypatch): + monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com") + monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url})) + client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "PASS"})) + + class FakeClient: + def __init__(self, *_): + pass + + async def __aenter__(self): + return client + + async def __aexit__(self, *_): + pass + + monkeypatch.setattr(service, "SearchConsoleClient", FakeClient) + async with AsyncSession(db_engine) as session: + site, _, _ = await seed_site(session) + sid = site.site_id + await session.commit() + await service.run_batch(session, SETTINGS) + await session.commit() + async with AsyncSession(db_engine) as session: + row = await session.get(site_search_status, sid) + assert row.sitemap_submitted_at + assert row.first_indexed_at + await service.run_batch(session, SETTINGS) + await session.commit() + client.submit_sitemap.assert_awaited_once() + client.inspect_url.assert_awaited_once() + + +async def test_cron_failure_is_isolated(monkeypatch): + monkeypatch.setattr(service, "load_settings", lambda: SETTINGS) + monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", AsyncMock(side_effect=RuntimeError("db"))) + await service.run_scheduled_check() + + +async def test_sitemap_http_parser(monkeypatch): + import httpx + + real_client = httpx.AsyncClient + transport = httpx.MockTransport(lambda request: httpx.Response(200, text=''' + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/s/stay</loc></url></urlset>''')) + monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw)) + assert await service.read_sitemap("https://example.com/sitemap.xml") == {status().page_url} + + +async def test_sitemap_redirect_is_not_followed(monkeypatch): + import httpx + + real_client = httpx.AsyncClient + transport = httpx.MockTransport(lambda request: httpx.Response(302, headers={"location": "http://internal/"})) + monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw)) + with pytest.raises(SearchConsoleError, match="SITEMAP_UNAVAILABLE"): + await service.read_sitemap("https://example.com/sitemap.xml") + + +@pytest.mark.parametrize("enabled,count", [("0", 0), ("1", 1)]) +def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count): + import scheduler + + instance = SimpleNamespace(add_job=lambda *a, **kw: jobs.append((a, kw)), + start=lambda: None, get_jobs=lambda: jobs) + jobs = [] + monkeypatch.setattr(scheduler, "_scheduler", None) + monkeypatch.setattr(scheduler, "AsyncIOScheduler", lambda **kw: instance) + monkeypatch.setenv("SCHEDULER_ENABLED", "1") + monkeypatch.setenv("GSC_ENABLED", enabled) + scheduler.start_scheduler() + + # ★ 전체 잡 수를 세지 않는다. 이 검사가 지키려는 것은 "GSC 잡이 GSC_ENABLED 에 따라 + # 붙는가" 하나인데, 총계로 세면 **스케줄러에 잡이 하나 늘 때마다 깨진다** + # (실제로 SNS 승인 만료 스윕이 붙으면서 깨졌다 — 2026-09-16 병합). + # id 로 그 잡만 집으면 다른 잡이 늘어도 이 검사는 자기 일만 본다. + gsc = [kw for _, kw in jobs if kw.get("id") == "search-console"] + assert len(gsc) == count + if gsc: + assert gsc[0]["minutes"] == 10 + assert gsc[0]["max_instances"] == 1 + + # SNS 승인 만료·중단 복구는 GSC 와 무관하게 늘 붙는다(scheduler/__init__ 주석). + assert [kw for _, kw in jobs if kw.get("id") == "social-sweep"] + + +def test_migration_matches_fresh_database_schema(): + from pathlib import Path + root = Path(__file__).resolve().parents[3] + migration = (root / "postgres-init/migrations/0014_search_console.sql").read_text() + ddl = migration[migration.index("CREATE TABLE"):].strip() + assert ddl in (root / "postgres-init/init-data/init.sql").read_text() 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/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 3d81522..791a6fe 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -114,7 +114,12 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면 # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url). - assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3" + # ★ 호스트를 박아 두지 않는다. 발행 오리진은 SITE_PUBLIC_HOST 에서 오고 기본값이 + # localhost 라(운영 주소를 기본으로 두면 로컬 빌드가 조용히 운영 주소를 굽는다), + # 테스트가 특정 도메인을 적으면 환경이 바뀔 때마다 여기서 깨진다. + from services import site_payload + + assert url == f"{site_payload.publish_origin()}/thumbs/butter.jpg?v=3" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" assert set(blob.uploads) == {name} assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") diff --git a/solution/backend/tests/test_social.py b/solution/backend/tests/test_social.py index 5d7be8b..73e6eb9 100644 --- a/solution/backend/tests/test_social.py +++ b/solution/backend/tests/test_social.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone import httpx import pytest from sqlalchemy import text +from common.enums import JobType from crud import social_crud from services import social_service as service from services.external import gemini_text, threads @@ -87,7 +88,7 @@ async def test_draft_dedup_owner_scope(client, auth_headers, db_engine): ) async with db_engine.begin() as c: assert ( - await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=8")) + await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=:t"), {"t": JobType.SOCIAL_DRAFT.value}) ).scalar_one() == 1 @@ -120,7 +121,7 @@ async def test_prefetch_read_only_one_time_cas(client, auth_headers, db_engine): assert second.json()["applied"] is False async with db_engine.begin() as c: assert ( - await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=9")) + await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=:t"), {"t": JobType.SOCIAL_POST.value}) ).scalar_one() == 1 @@ -262,7 +263,7 @@ async def test_screen_approval_without_contract_never_queues_post( assert approved.json()["applied"] is True async with db_engine.begin() as c: assert ( - await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=9")) + await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=:t"), {"t": JobType.SOCIAL_POST.value}) ).scalar_one() == 0 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/backend/tests/test_weather_notes.py b/solution/backend/tests/test_weather_notes.py new file mode 100644 index 0000000..a307806 --- /dev/null +++ b/solution/backend/tests/test_weather_notes.py @@ -0,0 +1,24 @@ +from services.weather_notes import weather_notes +from services.site_payload import _weather + + +def test_all_weather_cases_have_five_unique_lines(): + notes = weather_notes() + assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "비", "눈"} + assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"} + lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group] + assert len(lines) == len(set(lines)) == 50 + + +def test_templates_are_not_mutated_by_a_payload(): + notes = weather_notes() + notes["noteSets"]["비"].clear() + assert len(weather_notes()["noteSets"]["비"]) == 5 + + +def test_payload_attaches_notes_only_to_real_observations(): + assert _weather({"body": {}}) is None + weather = _weather({"body": {"temperature": 0, "weather_code": 71, "observed_at": "2026-09-15T12:00"}}) + assert weather["condition"] == "눈" + assert weather["temperature"] == 0 + assert len(weather["tempNoteSets"]["추움"]) == 5 diff --git a/solution/backend/worker/handlers.py b/solution/backend/worker/handlers.py index 00e8908..5249435 100644 --- a/solution/backend/worker/handlers.py +++ b/solution/backend/worker/handlers.py @@ -16,6 +16,7 @@ JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트 JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회) JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno) + JobType.ROLLBACK ✓ services/rollback_service.run_rollback — 예전 버전으로 공개 주소를 되돌림 JobType.AI_CHECK → reports 모듈이 붙을 때 """ @@ -82,9 +83,8 @@ def _register_builtin(): from services.copy_service import run_copy from services.story_service import run_local_sync from services.song_service import run_song + from services.rollback_service import run_rollback from services.social_service import run_draft, run_post - HANDLERS[JobType.SOCIAL_DRAFT.value] = run_draft - HANDLERS[JobType.SOCIAL_POST.value] = run_post from services.vision_service import run_vision if JobType.COLLECT.value not in HANDLERS: @@ -99,6 +99,12 @@ def _register_builtin(): HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync if JobType.SONG.value not in HANDLERS: HANDLERS[JobType.SONG.value] = run_song + if JobType.ROLLBACK.value not in HANDLERS: + HANDLERS[JobType.ROLLBACK.value] = run_rollback + if JobType.SOCIAL_DRAFT.value not in HANDLERS: + HANDLERS[JobType.SOCIAL_DRAFT.value] = run_draft + if JobType.SOCIAL_POST.value not in HANDLERS: + HANDLERS[JobType.SOCIAL_POST.value] = run_post _register_builtin() diff --git a/solution/backend/worker/runner.py b/solution/backend/worker/runner.py index 0adf2e6..aa14ebc 100644 --- a/solution/backend/worker/runner.py +++ b/solution/backend/worker/runner.py @@ -13,6 +13,7 @@ import asyncio from common.enums import JobStatus +from common.job_errors import PermanentJobError from common.logger import LOG from crud.job_crud import JobQueue, compute_backoff @@ -86,6 +87,12 @@ class Worker: st = await self.queue.fail(jid, self.worker_id, f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s", backoff) LOG.w(f"[{self.worker_id}] deadline {jid} → {JobStatus(st).name if st else '?'} " f"({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)") + except PermanentJobError as ex: + # ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석). + # 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로 + # 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다. + await self.queue.fail_permanent(jid, self.worker_id, f"{type(ex).__name__}: {ex}") + LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {type(ex).__name__}: {ex})") except Exception as ex: backoff = self.backoff_fn(job["attempts"]) st = await self.queue.fail(jid, self.worker_id, f"{type(ex).__name__}: {ex}", backoff) diff --git a/solution/frontend/public/brand/favicon-w4a.png b/solution/frontend/public/brand/favicon-w4a.png new file mode 100644 index 0000000..7ba4919 Binary files /dev/null and b/solution/frontend/public/brand/favicon-w4a.png differ diff --git a/solution/frontend/src/api/generated/auth/auth.ts b/solution/frontend/src/api/generated/auth/auth.ts index 65237d5..abfa289 100644 --- a/solution/frontend/src/api/generated/auth/auth.ts +++ b/solution/frontend/src/api/generated/auth/auth.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,8 +17,8 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, @@ -31,429 +28,576 @@ import type { ReqUpdateMe, ResLogin, ResMe, - ResRefreshToken -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResRefreshToken, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * id/pw 로 로그인하고 JWT 토큰을 발급한다. * @summary 로그인 */ export const login = ( - reqLogin: ReqLogin, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + reqLogin: ReqLogin, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLogin>( - {url: `/v1/auth/login`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqLogin, signal + return customFetch<ResLogin>( + { + url: `/v1/auth/login`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqLogin, + signal, }, - options); - } - + options, + ); +}; +export const getLoginMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof login>>, + TError, + { data: ReqLogin }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof login>>, + TError, + { data: ReqLogin }, + TContext +> => { + const mutationKey = ["login"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getLoginMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof login>>, TError,{data: ReqLogin}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof login>>, TError,{data: ReqLogin}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof login>>, + { data: ReqLogin } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['login']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return login(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type LoginMutationResult = NonNullable< + Awaited<ReturnType<typeof login>> +>; +export type LoginMutationBody = ReqLogin; +export type LoginMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof login>>, {data: ReqLogin}> = (props) => { - const {data} = props ?? {}; - - return login(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type LoginMutationResult = NonNullable<Awaited<ReturnType<typeof login>>> - export type LoginMutationBody = ReqLogin - export type LoginMutationError = void | HTTPValidationError - - /** +/** * @summary 로그인 */ -export const useLogin = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof login>>, TError,{data: ReqLogin}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof login>>, - TError, - {data: ReqLogin}, - TContext - > => { +export const useLogin = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof login>>, + TError, + { data: ReqLogin }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof login>>, + TError, + { data: ReqLogin }, + TContext +> => { + const mutationOptions = getLoginMutationOptions(options); - const mutationOptions = getLoginMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * id/pw 로 가입한다. 회사(테넌트) 1개가 함께 생기고, 성공하면 곧바로 로그인 토큰을 발급한다. * @summary 회원가입 */ export const signup = ( - reqSignup: ReqSignup, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + reqSignup: ReqSignup, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLogin>( - {url: `/v1/auth/signup`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSignup, signal + return customFetch<ResLogin>( + { + url: `/v1/auth/signup`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqSignup, + signal, }, - options); - } - + options, + ); +}; +export const getSignupMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof signup>>, + TError, + { data: ReqSignup }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof signup>>, + TError, + { data: ReqSignup }, + TContext +> => { + const mutationKey = ["signup"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getSignupMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof signup>>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof signup>>, TError,{data: ReqSignup}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof signup>>, + { data: ReqSignup } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['signup']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return signup(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type SignupMutationResult = NonNullable< + Awaited<ReturnType<typeof signup>> +>; +export type SignupMutationBody = ReqSignup; +export type SignupMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof signup>>, {data: ReqSignup}> = (props) => { - const {data} = props ?? {}; - - return signup(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type SignupMutationResult = NonNullable<Awaited<ReturnType<typeof signup>>> - export type SignupMutationBody = ReqSignup - export type SignupMutationError = void | HTTPValidationError - - /** +/** * @summary 회원가입 */ -export const useSignup = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof signup>>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof signup>>, - TError, - {data: ReqSignup}, - TContext - > => { +export const useSignup = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof signup>>, + TError, + { data: ReqSignup }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof signup>>, + TError, + { data: ReqSignup }, + TContext +> => { + const mutationOptions = getSignupMutationOptions(options); - const mutationOptions = getSignupMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 구글 ID 토큰(GIS credential)을 검증하고 JWT 토큰을 발급한다. 처음 온 계정은 그 자리에서 만든다. * @summary 구글 로그인 */ export const googleLogin = ( - reqGoogleLogin: ReqGoogleLogin, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + reqGoogleLogin: ReqGoogleLogin, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLogin>( - {url: `/v1/auth/google`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqGoogleLogin, signal + return customFetch<ResLogin>( + { + url: `/v1/auth/google`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqGoogleLogin, + signal, }, - options); - } - + options, + ); +}; +export const getGoogleLoginMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof googleLogin>>, + TError, + { data: ReqGoogleLogin }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof googleLogin>>, + TError, + { data: ReqGoogleLogin }, + TContext +> => { + const mutationKey = ["googleLogin"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getGoogleLoginMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, TError,{data: ReqGoogleLogin}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof googleLogin>>, + { data: ReqGoogleLogin } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['googleLogin']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return googleLogin(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type GoogleLoginMutationResult = NonNullable< + Awaited<ReturnType<typeof googleLogin>> +>; +export type GoogleLoginMutationBody = ReqGoogleLogin; +export type GoogleLoginMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof googleLogin>>, {data: ReqGoogleLogin}> = (props) => { - const {data} = props ?? {}; - - return googleLogin(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type GoogleLoginMutationResult = NonNullable<Awaited<ReturnType<typeof googleLogin>>> - export type GoogleLoginMutationBody = ReqGoogleLogin - export type GoogleLoginMutationError = void | HTTPValidationError - - /** +/** * @summary 구글 로그인 */ -export const useGoogleLogin = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof googleLogin>>, - TError, - {data: ReqGoogleLogin}, - TContext - > => { +export const useGoogleLogin = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof googleLogin>>, + TError, + { data: ReqGoogleLogin }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof googleLogin>>, + TError, + { data: ReqGoogleLogin }, + TContext +> => { + const mutationOptions = getGoogleLoginMutationOptions(options); - const mutationOptions = getGoogleLoginMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ export const refreshToken = ( - - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResRefreshToken>( - {url: `/v1/auth/refresh_token`, method: 'POST', signal - }, - options); - } - + return customFetch<ResRefreshToken>( + { url: `/v1/auth/refresh_token`, method: "POST", signal }, + options, + ); +}; +export const getRefreshTokenMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof refreshToken>>, + TError, + void, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof refreshToken>>, + TError, + void, + TContext +> => { + const mutationKey = ["refreshToken"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getRefreshTokenMutationOptions = <TError = void, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof refreshToken>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof refreshToken>>, TError,void, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof refreshToken>>, + void + > = () => { + return refreshToken(requestOptions); + }; -const mutationKey = ['refreshToken']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return { mutationFn, ...mutationOptions }; +}; - +export type RefreshTokenMutationResult = NonNullable< + Awaited<ReturnType<typeof refreshToken>> +>; +export type RefreshTokenMutationError = void; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof refreshToken>>, void> = () => { - - - return refreshToken(requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type RefreshTokenMutationResult = NonNullable<Awaited<ReturnType<typeof refreshToken>>> - - export type RefreshTokenMutationError = void - - /** +/** * @summary 액세스 토큰 갱신 */ -export const useRefreshToken = <TError = void, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof refreshToken>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof refreshToken>>, - TError, - void, - TContext - > => { +export const useRefreshToken = <TError = void, TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof refreshToken>>, + TError, + void, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof refreshToken>>, + TError, + void, + TContext +> => { + const mutationOptions = getRefreshTokenMutationOptions(options); - const mutationOptions = getRefreshTokenMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+회사 정보를 반환한다. * @summary 내 정보 */ export const me = ( - - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResMe>( - {url: `/v1/auth/me`, method: 'GET', signal - }, - options); - } - - - + return customFetch<ResMe>( + { url: `/v1/auth/me`, method: "GET", signal }, + options, + ); +}; export const getMeQueryKey = () => { - return [ - `/v1/auth/me` - ] as const; - } + return [`/v1/auth/me`] as const; +}; - -export const getMeQueryOptions = <TData = Awaited<ReturnType<typeof me>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} -) => { +export const getMeQueryOptions = < + TData = Awaited<ReturnType<typeof me>>, + TError = void, +>(options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getMeQueryKey(); - const queryKey = queryOptions?.queryKey ?? getMeQueryKey(); + const queryFn: QueryFunction<Awaited<ReturnType<typeof me>>> = ({ signal }) => + me(requestOptions, signal); - - - const queryFn: QueryFunction<Awaited<ReturnType<typeof me>>> = ({ signal }) => me(requestOptions, signal); - - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type MeQueryResult = NonNullable<Awaited<ReturnType<typeof me>>> -export type MeQueryError = void + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof me>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; +export type MeQueryResult = NonNullable<Awaited<ReturnType<typeof me>>>; +export type MeQueryError = void; export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>( - options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData>> & Pick< + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof me>>, TError, Awaited<ReturnType<typeof me>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData>> & Pick< + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof me>>, TError, Awaited<ReturnType<typeof me>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 내 정보 */ export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof me>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getMeQueryOptions(options); - const queryOptions = getMeQueryOptions(options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가). * @summary 내 정보 수정 */ export const updateMe = ( - reqUpdateMe: ReqUpdateMe, - options?: SecondParameter<typeof customFetch>,) => { - - - return customFetch<ResMe>( - {url: `/v1/auth/me`, method: 'PATCH', - headers: {'Content-Type': 'application/json', }, - data: reqUpdateMe + reqUpdateMe: ReqUpdateMe, + options?: SecondParameter<typeof customFetch>, +) => { + return customFetch<ResMe>( + { + url: `/v1/auth/me`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateMe, }, - options); - } - + options, + ); +}; +export const getUpdateMeMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updateMe>>, + TError, + { data: ReqUpdateMe }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof updateMe>>, + TError, + { data: ReqUpdateMe }, + TContext +> => { + const mutationKey = ["updateMe"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getUpdateMeMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof updateMe>>, + { data: ReqUpdateMe } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['updateMe']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return updateMe(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type UpdateMeMutationResult = NonNullable< + Awaited<ReturnType<typeof updateMe>> +>; +export type UpdateMeMutationBody = ReqUpdateMe; +export type UpdateMeMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMe>>, {data: ReqUpdateMe}> = (props) => { - const {data} = props ?? {}; - - return updateMe(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type UpdateMeMutationResult = NonNullable<Awaited<ReturnType<typeof updateMe>>> - export type UpdateMeMutationBody = ReqUpdateMe - export type UpdateMeMutationError = void | HTTPValidationError - - /** +/** * @summary 내 정보 수정 */ -export const useUpdateMe = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof updateMe>>, - TError, - {data: ReqUpdateMe}, - TContext - > => { +export const useUpdateMe = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updateMe>>, + TError, + { data: ReqUpdateMe }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof updateMe>>, + TError, + { data: ReqUpdateMe }, + TContext +> => { + const mutationOptions = getUpdateMeMutationOptions(options); - const mutationOptions = getUpdateMeMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - \ No newline at end of file + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/default/default.ts b/solution/frontend/src/api/generated/default/default.ts index 6b08931..3e5ee12 100644 --- a/solution/frontend/src/api/generated/default/default.ts +++ b/solution/frontend/src/api/generated/default/default.ts @@ -4,9 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useQuery -} from '@tanstack/react-query'; +import { useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -16,105 +14,163 @@ import type { QueryKey, UndefinedInitialDataOptions, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; - -import { customFetch } from '../../mutator/custom-fetch'; + UseQueryResult, +} from "@tanstack/react-query"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * @summary Healthz */ export const healthzHealthzGet = ( - - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<unknown>( - {url: `/healthz`, method: 'GET', signal - }, - options); - } - - - + return customFetch<unknown>( + { url: `/healthz`, method: "GET", signal }, + options, + ); +}; export const getHealthzHealthzGetQueryKey = () => { - return [ - `/healthz` - ] as const; - } + return [`/healthz`] as const; +}; - -export const getHealthzHealthzGetQueryOptions = <TData = Awaited<ReturnType<typeof healthzHealthzGet>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} -) => { +export const getHealthzHealthzGetQueryOptions = < + TData = Awaited<ReturnType<typeof healthzHealthzGet>>, + TError = void, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getHealthzHealthzGetQueryKey(); - const queryKey = queryOptions?.queryKey ?? getHealthzHealthzGetQueryKey(); + const queryFn: QueryFunction< + Awaited<ReturnType<typeof healthzHealthzGet>> + > = ({ signal }) => healthzHealthzGet(requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof healthzHealthzGet>>> = ({ signal }) => healthzHealthzGet(requestOptions, signal); +export type HealthzHealthzGetQueryResult = NonNullable< + Awaited<ReturnType<typeof healthzHealthzGet>> +>; +export type HealthzHealthzGetQueryError = void; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type HealthzHealthzGetQueryResult = NonNullable<Awaited<ReturnType<typeof healthzHealthzGet>>> -export type HealthzHealthzGetQueryError = void - - -export function useHealthzHealthzGet<TData = Awaited<ReturnType<typeof healthzHealthzGet>>, TError = void>( - options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData>> & Pick< +export function useHealthzHealthzGet< + TData = Awaited<ReturnType<typeof healthzHealthzGet>>, + TError = void, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof healthzHealthzGet>>, TError, Awaited<ReturnType<typeof healthzHealthzGet>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useHealthzHealthzGet<TData = Awaited<ReturnType<typeof healthzHealthzGet>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useHealthzHealthzGet< + TData = Awaited<ReturnType<typeof healthzHealthzGet>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof healthzHealthzGet>>, TError, Awaited<ReturnType<typeof healthzHealthzGet>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useHealthzHealthzGet<TData = Awaited<ReturnType<typeof healthzHealthzGet>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useHealthzHealthzGet< + TData = Awaited<ReturnType<typeof healthzHealthzGet>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary Healthz */ -export function useHealthzHealthzGet<TData = Awaited<ReturnType<typeof healthzHealthzGet>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthzHealthzGet>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useHealthzHealthzGet< + TData = Awaited<ReturnType<typeof healthzHealthzGet>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof healthzHealthzGet>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getHealthzHealthzGetQueryOptions(options); - const queryOptions = getHealthzHealthzGetQueryOptions(options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - - diff --git a/solution/frontend/src/api/generated/fact/fact.ts b/solution/frontend/src/api/generated/fact/fact.ts index a068d43..e8d8d14 100644 --- a/solution/frontend/src/api/generated/fact/fact.ts +++ b/solution/frontend/src/api/generated/fact/fact.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,8 +17,8 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, @@ -32,407 +29,595 @@ import type { ResCategorySchema, ResExtractFacts, ResFact, - ResFactList -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResFactList, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * 이 사업장의 업종에 존재하는 fact 필드 정의. 관리 화면이 입력 폼을 그리는 데 쓴다. * @summary 업종 fact 스키마 */ export const getSchema = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResCategorySchema>( - {url: `/v1/place/${placeId}/fact/schema`, method: 'GET', signal - }, - options); - } - + return customFetch<ResCategorySchema>( + { url: `/v1/place/${placeId}/fact/schema`, method: "GET", signal }, + options, + ); +}; +export const getGetSchemaQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/fact/schema`] as const; +}; - -export const getGetSchemaQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/fact/schema` - ] as const; - } - - -export const getGetSchemaQueryOptions = <TData = Awaited<ReturnType<typeof getSchema>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetSchemaQueryOptions = < + TData = Awaited<ReturnType<typeof getSchema>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetSchemaQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getGetSchemaQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getSchema>>> = ({ + signal, + }) => getSchema(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getSchema>>> = ({ signal }) => getSchema(placeId, requestOptions, signal); +export type GetSchemaQueryResult = NonNullable< + Awaited<ReturnType<typeof getSchema>> +>; +export type GetSchemaQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetSchemaQueryResult = NonNullable<Awaited<ReturnType<typeof getSchema>>> -export type GetSchemaQueryError = void | HTTPValidationError - - -export function useGetSchema<TData = Awaited<ReturnType<typeof getSchema>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData>> & Pick< +export function useGetSchema< + TData = Awaited<ReturnType<typeof getSchema>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getSchema>>, TError, Awaited<ReturnType<typeof getSchema>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSchema<TData = Awaited<ReturnType<typeof getSchema>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSchema< + TData = Awaited<ReturnType<typeof getSchema>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getSchema>>, TError, Awaited<ReturnType<typeof getSchema>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSchema<TData = Awaited<ReturnType<typeof getSchema>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSchema< + TData = Awaited<ReturnType<typeof getSchema>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 업종 fact 스키마 */ -export function useGetSchema<TData = Awaited<ReturnType<typeof getSchema>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetSchema< + TData = Awaited<ReturnType<typeof getSchema>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSchema>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetSchemaQueryOptions(placeId, options); - const queryOptions = getGetSchemaQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * publishable_only=true 면 ★ 사이트에 나갈 수 있는 것(VERIFIED·CORRECTED)만. * @summary fact 목록 */ export const listFacts = ( - placeId: string, - params?: ListFactsParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params?: ListFactsParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFactList>( - {url: `/v1/place/${placeId}/fact/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResFactList>( + { url: `/v1/place/${placeId}/fact/list`, method: "GET", params, signal }, + options, + ); +}; - - -export const getListFactsQueryKey = (placeId?: string, - params?: ListFactsParams,) => { - return [ - `/v1/place/${placeId}/fact/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListFactsQueryOptions = <TData = Awaited<ReturnType<typeof listFacts>>, TError = void | HTTPValidationError>(placeId: string, - params?: ListFactsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListFactsQueryKey = ( + placeId?: string, + params?: ListFactsParams, ) => { + return [ + `/v1/place/${placeId}/fact/list`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getListFactsQueryOptions = < + TData = Awaited<ReturnType<typeof listFacts>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFactsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListFactsQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getListFactsQueryKey(placeId, params); - + const queryFn: QueryFunction<Awaited<ReturnType<typeof listFacts>>> = ({ + signal, + }) => listFacts(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof listFacts>>> = ({ signal }) => listFacts(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - +export type ListFactsQueryResult = NonNullable< + Awaited<ReturnType<typeof listFacts>> +>; +export type ListFactsQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListFactsQueryResult = NonNullable<Awaited<ReturnType<typeof listFacts>>> -export type ListFactsQueryError = void | HTTPValidationError - - -export function useListFacts<TData = Awaited<ReturnType<typeof listFacts>>, TError = void | HTTPValidationError>( - placeId: string, - params: undefined | ListFactsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData>> & Pick< +export function useListFacts< + TData = Awaited<ReturnType<typeof listFacts>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: undefined | ListFactsParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listFacts>>, TError, Awaited<ReturnType<typeof listFacts>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListFacts<TData = Awaited<ReturnType<typeof listFacts>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFactsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListFacts< + TData = Awaited<ReturnType<typeof listFacts>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFactsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listFacts>>, TError, Awaited<ReturnType<typeof listFacts>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListFacts<TData = Awaited<ReturnType<typeof listFacts>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFactsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListFacts< + TData = Awaited<ReturnType<typeof listFacts>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFactsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary fact 목록 */ -export function useListFacts<TData = Awaited<ReturnType<typeof listFacts>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFactsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListFacts< + TData = Awaited<ReturnType<typeof listFacts>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFactsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFacts>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListFactsQueryOptions(placeId, params, options); - const queryOptions = getListFactsQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 같은 key 의 활성 fact 는 EXPIRED 로 밀리고 새 행이 들어간다(값 이력 보존). ★ 자동 수집(api/crawl/llm)은 사장님 수정본(CORRECTED)을 덮어쓸 수 없다. * @summary fact 기록 */ export const upsertFact = ( - placeId: string, - reqUpsertFact: ReqUpsertFact, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqUpsertFact: ReqUpsertFact, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFact>( - {url: `/v1/place/${placeId}/fact`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqUpsertFact, signal + return customFetch<ResFact>( + { + url: `/v1/place/${placeId}/fact`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqUpsertFact, + signal, }, - options); - } - + options, + ); +}; +export const getUpsertFactMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof upsertFact>>, + TError, + { placeId: string; data: ReqUpsertFact }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof upsertFact>>, + TError, + { placeId: string; data: ReqUpsertFact }, + TContext +> => { + const mutationKey = ["upsertFact"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getUpsertFactMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upsertFact>>, TError,{placeId: string;data: ReqUpsertFact}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof upsertFact>>, TError,{placeId: string;data: ReqUpsertFact}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof upsertFact>>, + { placeId: string; data: ReqUpsertFact } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['upsertFact']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return upsertFact(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type UpsertFactMutationResult = NonNullable< + Awaited<ReturnType<typeof upsertFact>> +>; +export type UpsertFactMutationBody = ReqUpsertFact; +export type UpsertFactMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof upsertFact>>, {placeId: string;data: ReqUpsertFact}> = (props) => { - const {placeId,data} = props ?? {}; - - return upsertFact(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type UpsertFactMutationResult = NonNullable<Awaited<ReturnType<typeof upsertFact>>> - export type UpsertFactMutationBody = ReqUpsertFact - export type UpsertFactMutationError = void | HTTPValidationError - - /** +/** * @summary fact 기록 */ -export const useUpsertFact = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upsertFact>>, TError,{placeId: string;data: ReqUpsertFact}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof upsertFact>>, - TError, - {placeId: string;data: ReqUpsertFact}, - TContext - > => { +export const useUpsertFact = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof upsertFact>>, + TError, + { placeId: string; data: ReqUpsertFact }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof upsertFact>>, + TError, + { placeId: string; data: ReqUpsertFact }, + TContext +> => { + const mutationOptions = getUpsertFactMutationOptions(options); - const mutationOptions = getUpsertFactMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 허용 전이만 통과한다(FACT_STATUS_TRANSITIONS). CORRECTED 로 갈 때는 value 가 필수다. * @summary 검증 상태 전이 */ export const transitionFact = ( - placeId: string, - factId: string, - reqTransitionFact: ReqTransitionFact, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + factId: string, + reqTransitionFact: ReqTransitionFact, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFact>( - {url: `/v1/place/${placeId}/fact/${factId}/transition`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqTransitionFact, signal + return customFetch<ResFact>( + { + url: `/v1/place/${placeId}/fact/${factId}/transition`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqTransitionFact, + signal, }, - options); - } - + options, + ); +}; +export const getTransitionFactMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof transitionFact>>, + TError, + { placeId: string; factId: string; data: ReqTransitionFact }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof transitionFact>>, + TError, + { placeId: string; factId: string; data: ReqTransitionFact }, + TContext +> => { + const mutationKey = ["transitionFact"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getTransitionFactMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof transitionFact>>, TError,{placeId: string;factId: string;data: ReqTransitionFact}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof transitionFact>>, TError,{placeId: string;factId: string;data: ReqTransitionFact}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof transitionFact>>, + { placeId: string; factId: string; data: ReqTransitionFact } + > = (props) => { + const { placeId, factId, data } = props ?? {}; -const mutationKey = ['transitionFact']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return transitionFact(placeId, factId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type TransitionFactMutationResult = NonNullable< + Awaited<ReturnType<typeof transitionFact>> +>; +export type TransitionFactMutationBody = ReqTransitionFact; +export type TransitionFactMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof transitionFact>>, {placeId: string;factId: string;data: ReqTransitionFact}> = (props) => { - const {placeId,factId,data} = props ?? {}; - - return transitionFact(placeId,factId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type TransitionFactMutationResult = NonNullable<Awaited<ReturnType<typeof transitionFact>>> - export type TransitionFactMutationBody = ReqTransitionFact - export type TransitionFactMutationError = void | HTTPValidationError - - /** +/** * @summary 검증 상태 전이 */ -export const useTransitionFact = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof transitionFact>>, TError,{placeId: string;factId: string;data: ReqTransitionFact}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof transitionFact>>, - TError, - {placeId: string;factId: string;data: ReqTransitionFact}, - TContext - > => { +export const useTransitionFact = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof transitionFact>>, + TError, + { placeId: string; factId: string; data: ReqTransitionFact }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof transitionFact>>, + TError, + { placeId: string; factId: string; data: ReqTransitionFact }, + TContext +> => { + const mutationOptions = getTransitionFactMutationOptions(options); - const mutationOptions = getTransitionFactMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 사장님이 붙여넣은 소개글·안내문에서 업종 스키마 fact 를 뽑는다. ★ 뽑은 값의 **근거 문장이 원문에 실제로 있는지 대조**해 통과한 것만 저장한다. 저장된 값은 전부 후보(UNVERIFIED)라 사장님이 확인해야 사이트에 나간다. 버려진 항목은 사유와 함께 돌려준다. * @summary 붙여넣은 텍스트에서 fact 추출 */ export const extractFactsFromText = ( - placeId: string, - reqExtractFacts: ReqExtractFacts, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqExtractFacts: ReqExtractFacts, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResExtractFacts>( - {url: `/v1/place/${placeId}/fact/extract`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqExtractFacts, signal + return customFetch<ResExtractFacts>( + { + url: `/v1/place/${placeId}/fact/extract`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqExtractFacts, + signal, }, - options); - } - + options, + ); +}; +export const getExtractFactsFromTextMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof extractFactsFromText>>, + TError, + { placeId: string; data: ReqExtractFacts }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof extractFactsFromText>>, + TError, + { placeId: string; data: ReqExtractFacts }, + TContext +> => { + const mutationKey = ["extractFactsFromText"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getExtractFactsFromTextMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractFactsFromText>>, TError,{placeId: string;data: ReqExtractFacts}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof extractFactsFromText>>, TError,{placeId: string;data: ReqExtractFacts}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof extractFactsFromText>>, + { placeId: string; data: ReqExtractFacts } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['extractFactsFromText']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return extractFactsFromText(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type ExtractFactsFromTextMutationResult = NonNullable< + Awaited<ReturnType<typeof extractFactsFromText>> +>; +export type ExtractFactsFromTextMutationBody = ReqExtractFacts; +export type ExtractFactsFromTextMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof extractFactsFromText>>, {placeId: string;data: ReqExtractFacts}> = (props) => { - const {placeId,data} = props ?? {}; - - return extractFactsFromText(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type ExtractFactsFromTextMutationResult = NonNullable<Awaited<ReturnType<typeof extractFactsFromText>>> - export type ExtractFactsFromTextMutationBody = ReqExtractFacts - export type ExtractFactsFromTextMutationError = void | HTTPValidationError - - /** +/** * @summary 붙여넣은 텍스트에서 fact 추출 */ -export const useExtractFactsFromText = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractFactsFromText>>, TError,{placeId: string;data: ReqExtractFacts}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof extractFactsFromText>>, - TError, - {placeId: string;data: ReqExtractFacts}, - TContext - > => { +export const useExtractFactsFromText = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof extractFactsFromText>>, + TError, + { placeId: string; data: ReqExtractFacts }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof extractFactsFromText>>, + TError, + { placeId: string; data: ReqExtractFacts }, + TContext +> => { + const mutationOptions = getExtractFactsFromTextMutationOptions(options); - const mutationOptions = getExtractFactsFromTextMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - \ No newline at end of file + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/faq/faq.ts b/solution/frontend/src/api/generated/faq/faq.ts index 9b6b4a1..32e490d 100644 --- a/solution/frontend/src/api/generated/faq/faq.ts +++ b/solution/frontend/src/api/generated/faq/faq.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,8 +17,8 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, @@ -29,248 +26,356 @@ import type { ReqCreateFaq, ReqTransitionFaq, ResFaq, - ResFaqList -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResFaqList, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * publishable_only=true 면 ★ 사이트에 나갈 수 있는 것(VERIFIED·CORRECTED)만. 기본은 승인 대기(UNVERIFIED)까지 함께 준다 — 승인할 대상을 봐야 하기 때문이다. * @summary FAQ 목록 */ export const listFaqs = ( - placeId: string, - params?: ListFaqsParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params?: ListFaqsParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFaqList>( - {url: `/v1/place/${placeId}/faq/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResFaqList>( + { url: `/v1/place/${placeId}/faq/list`, method: "GET", params, signal }, + options, + ); +}; - - -export const getListFaqsQueryKey = (placeId?: string, - params?: ListFaqsParams,) => { - return [ - `/v1/place/${placeId}/faq/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListFaqsQueryOptions = <TData = Awaited<ReturnType<typeof listFaqs>>, TError = void | HTTPValidationError>(placeId: string, - params?: ListFaqsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListFaqsQueryKey = ( + placeId?: string, + params?: ListFaqsParams, ) => { + return [ + `/v1/place/${placeId}/faq/list`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getListFaqsQueryOptions = < + TData = Awaited<ReturnType<typeof listFaqs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFaqsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListFaqsQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getListFaqsQueryKey(placeId, params); - + const queryFn: QueryFunction<Awaited<ReturnType<typeof listFaqs>>> = ({ + signal, + }) => listFaqs(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof listFaqs>>> = ({ signal }) => listFaqs(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - +export type ListFaqsQueryResult = NonNullable< + Awaited<ReturnType<typeof listFaqs>> +>; +export type ListFaqsQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListFaqsQueryResult = NonNullable<Awaited<ReturnType<typeof listFaqs>>> -export type ListFaqsQueryError = void | HTTPValidationError - - -export function useListFaqs<TData = Awaited<ReturnType<typeof listFaqs>>, TError = void | HTTPValidationError>( - placeId: string, - params: undefined | ListFaqsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData>> & Pick< +export function useListFaqs< + TData = Awaited<ReturnType<typeof listFaqs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: undefined | ListFaqsParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listFaqs>>, TError, Awaited<ReturnType<typeof listFaqs>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListFaqs<TData = Awaited<ReturnType<typeof listFaqs>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFaqsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListFaqs< + TData = Awaited<ReturnType<typeof listFaqs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFaqsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listFaqs>>, TError, Awaited<ReturnType<typeof listFaqs>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListFaqs<TData = Awaited<ReturnType<typeof listFaqs>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFaqsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListFaqs< + TData = Awaited<ReturnType<typeof listFaqs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFaqsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary FAQ 목록 */ -export function useListFaqs<TData = Awaited<ReturnType<typeof listFaqs>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListFaqsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListFaqs< + TData = Awaited<ReturnType<typeof listFaqs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListFaqsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listFaqs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListFaqsQueryOptions(placeId, params, options); - const queryOptions = getListFaqsQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 사장님이 직접 쓴 FAQ. fact 의 owner 입력과 같이 바로 노출값(VERIFIED)이 된다 — 사람이 곧 출처다. 출처(generated_by)는 서버가 OWNER 로 고정한다. * @summary FAQ 직접 추가 */ export const createFaq = ( - placeId: string, - reqCreateFaq: ReqCreateFaq, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqCreateFaq: ReqCreateFaq, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFaq>( - {url: `/v1/place/${placeId}/faq`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateFaq, signal + return customFetch<ResFaq>( + { + url: `/v1/place/${placeId}/faq`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateFaq, + signal, }, - options); - } - + options, + ); +}; +export const getCreateFaqMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createFaq>>, + TError, + { placeId: string; data: ReqCreateFaq }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof createFaq>>, + TError, + { placeId: string; data: ReqCreateFaq }, + TContext +> => { + const mutationKey = ["createFaq"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getCreateFaqMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createFaq>>, TError,{placeId: string;data: ReqCreateFaq}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof createFaq>>, TError,{placeId: string;data: ReqCreateFaq}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof createFaq>>, + { placeId: string; data: ReqCreateFaq } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['createFaq']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return createFaq(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type CreateFaqMutationResult = NonNullable< + Awaited<ReturnType<typeof createFaq>> +>; +export type CreateFaqMutationBody = ReqCreateFaq; +export type CreateFaqMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof createFaq>>, {placeId: string;data: ReqCreateFaq}> = (props) => { - const {placeId,data} = props ?? {}; - - return createFaq(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateFaqMutationResult = NonNullable<Awaited<ReturnType<typeof createFaq>>> - export type CreateFaqMutationBody = ReqCreateFaq - export type CreateFaqMutationError = void | HTTPValidationError - - /** +/** * @summary FAQ 직접 추가 */ -export const useCreateFaq = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createFaq>>, TError,{placeId: string;data: ReqCreateFaq}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof createFaq>>, - TError, - {placeId: string;data: ReqCreateFaq}, - TContext - > => { +export const useCreateFaq = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createFaq>>, + TError, + { placeId: string; data: ReqCreateFaq }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof createFaq>>, + TError, + { placeId: string; data: ReqCreateFaq }, + TContext +> => { + const mutationOptions = getCreateFaqMutationOptions(options); - const mutationOptions = getCreateFaqMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 허용 전이만 통과한다(FACT_STATUS_TRANSITIONS — fact 와 같은 표). CORRECTED 로 갈 때는 고친 question/answer 중 하나 이상이 필요하고, 그 뒤로는 ★ 잠긴 상태라 재생성이 덮어쓰지 못한다. * @summary 검증 상태 전이(승인·정정·반려) */ export const transitionFaq = ( - placeId: string, - faqId: string, - reqTransitionFaq: ReqTransitionFaq, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + faqId: string, + reqTransitionFaq: ReqTransitionFaq, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResFaq>( - {url: `/v1/place/${placeId}/faq/${faqId}/transition`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqTransitionFaq, signal + return customFetch<ResFaq>( + { + url: `/v1/place/${placeId}/faq/${faqId}/transition`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqTransitionFaq, + signal, }, - options); - } - + options, + ); +}; +export const getTransitionFaqMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof transitionFaq>>, + TError, + { placeId: string; faqId: string; data: ReqTransitionFaq }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof transitionFaq>>, + TError, + { placeId: string; faqId: string; data: ReqTransitionFaq }, + TContext +> => { + const mutationKey = ["transitionFaq"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getTransitionFaqMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof transitionFaq>>, TError,{placeId: string;faqId: string;data: ReqTransitionFaq}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof transitionFaq>>, TError,{placeId: string;faqId: string;data: ReqTransitionFaq}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof transitionFaq>>, + { placeId: string; faqId: string; data: ReqTransitionFaq } + > = (props) => { + const { placeId, faqId, data } = props ?? {}; -const mutationKey = ['transitionFaq']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return transitionFaq(placeId, faqId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type TransitionFaqMutationResult = NonNullable< + Awaited<ReturnType<typeof transitionFaq>> +>; +export type TransitionFaqMutationBody = ReqTransitionFaq; +export type TransitionFaqMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof transitionFaq>>, {placeId: string;faqId: string;data: ReqTransitionFaq}> = (props) => { - const {placeId,faqId,data} = props ?? {}; - - return transitionFaq(placeId,faqId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type TransitionFaqMutationResult = NonNullable<Awaited<ReturnType<typeof transitionFaq>>> - export type TransitionFaqMutationBody = ReqTransitionFaq - export type TransitionFaqMutationError = void | HTTPValidationError - - /** +/** * @summary 검증 상태 전이(승인·정정·반려) */ -export const useTransitionFaq = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof transitionFaq>>, TError,{placeId: string;faqId: string;data: ReqTransitionFaq}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof transitionFaq>>, - TError, - {placeId: string;faqId: string;data: ReqTransitionFaq}, - TContext - > => { +export const useTransitionFaq = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof transitionFaq>>, + TError, + { placeId: string; faqId: string; data: ReqTransitionFaq }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof transitionFaq>>, + TError, + { placeId: string; faqId: string; data: ReqTransitionFaq }, + TContext +> => { + const mutationOptions = getTransitionFaqMutationOptions(options); - const mutationOptions = getTransitionFaqMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - \ No newline at end of file + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/image/image.ts b/solution/frontend/src/api/generated/image/image.ts new file mode 100644 index 0000000..fc48663 --- /dev/null +++ b/solution/frontend/src/api/generated/image/image.ts @@ -0,0 +1,166 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { HTTPValidationError, RelayParams } from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; + +/** + * 인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 +것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다). + * @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다 + */ +export const relay = ( + params: RelayParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<unknown>( + { url: `/v1/image/relay`, method: "GET", params, signal }, + options, + ); +}; + +export const getRelayQueryKey = (params?: RelayParams) => { + return [`/v1/image/relay`, ...(params ? [params] : [])] as const; +}; + +export const getRelayQueryOptions = < + TData = Awaited<ReturnType<typeof relay>>, + TError = HTTPValidationError, +>( + params: RelayParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof relay>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getRelayQueryKey(params); + + const queryFn: QueryFunction<Awaited<ReturnType<typeof relay>>> = ({ + signal, + }) => relay(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof relay>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; + +export type RelayQueryResult = NonNullable<Awaited<ReturnType<typeof relay>>>; +export type RelayQueryError = HTTPValidationError; + +export function useRelay< + TData = Awaited<ReturnType<typeof relay>>, + TError = HTTPValidationError, +>( + params: RelayParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof relay>>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited<ReturnType<typeof relay>>, + TError, + Awaited<ReturnType<typeof relay>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useRelay< + TData = Awaited<ReturnType<typeof relay>>, + TError = HTTPValidationError, +>( + params: RelayParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof relay>>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited<ReturnType<typeof relay>>, + TError, + Awaited<ReturnType<typeof relay>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useRelay< + TData = Awaited<ReturnType<typeof relay>>, + TError = HTTPValidationError, +>( + params: RelayParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof relay>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +/** + * @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다 + */ + +export function useRelay< + TData = Awaited<ReturnType<typeof relay>>, + TError = HTTPValidationError, +>( + params: RelayParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof relay>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getRelayQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/solution/frontend/src/api/generated/job/job.ts b/solution/frontend/src/api/generated/job/job.ts index 50eeca5..1d18b88 100644 --- a/solution/frontend/src/api/generated/job/job.ts +++ b/solution/frontend/src/api/generated/job/job.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,268 +17,374 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; -import type { - HTTPValidationError, - ResJob, - ResJobOps -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; +import type { HTTPValidationError, ResJob, ResJobOps } from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * 상태별 카운트 + 큐 지연 + 최근 1시간 DEAD + 좀비(stuck). 내부 운영 전용. * @summary 큐 운영 스냅샷 */ export const jobOps = ( - - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResJobOps>( - {url: `/v1/job/ops`, method: 'GET', signal - }, - options); - } - - - + return customFetch<ResJobOps>( + { url: `/v1/job/ops`, method: "GET", signal }, + options, + ); +}; export const getJobOpsQueryKey = () => { - return [ - `/v1/job/ops` - ] as const; - } + return [`/v1/job/ops`] as const; +}; - -export const getJobOpsQueryOptions = <TData = Awaited<ReturnType<typeof jobOps>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} -) => { +export const getJobOpsQueryOptions = < + TData = Awaited<ReturnType<typeof jobOps>>, + TError = void, +>(options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getJobOpsQueryKey(); - const queryKey = queryOptions?.queryKey ?? getJobOpsQueryKey(); + const queryFn: QueryFunction<Awaited<ReturnType<typeof jobOps>>> = ({ + signal, + }) => jobOps(requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof jobOps>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof jobOps>>> = ({ signal }) => jobOps(requestOptions, signal); +export type JobOpsQueryResult = NonNullable<Awaited<ReturnType<typeof jobOps>>>; +export type JobOpsQueryError = void; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type JobOpsQueryResult = NonNullable<Awaited<ReturnType<typeof jobOps>>> -export type JobOpsQueryError = void - - -export function useJobOps<TData = Awaited<ReturnType<typeof jobOps>>, TError = void>( - options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData>> & Pick< +export function useJobOps< + TData = Awaited<ReturnType<typeof jobOps>>, + TError = void, +>( + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof jobOps>>, TError, Awaited<ReturnType<typeof jobOps>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useJobOps<TData = Awaited<ReturnType<typeof jobOps>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useJobOps< + TData = Awaited<ReturnType<typeof jobOps>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof jobOps>>, TError, Awaited<ReturnType<typeof jobOps>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useJobOps<TData = Awaited<ReturnType<typeof jobOps>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useJobOps< + TData = Awaited<ReturnType<typeof jobOps>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 큐 운영 스냅샷 */ -export function useJobOps<TData = Awaited<ReturnType<typeof jobOps>>, TError = void>( - options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useJobOps< + TData = Awaited<ReturnType<typeof jobOps>>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof jobOps>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getJobOpsQueryOptions(options); - const queryOptions = getJobOpsQueryOptions(options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * @summary 잡 상태 조회(폴링) */ export const getJob = ( - jobId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + jobId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResJob>( - {url: `/v1/job/${jobId}`, method: 'GET', signal - }, - options); - } - + return customFetch<ResJob>( + { url: `/v1/job/${jobId}`, method: "GET", signal }, + options, + ); +}; +export const getGetJobQueryKey = (jobId?: string) => { + return [`/v1/job/${jobId}`] as const; +}; - -export const getGetJobQueryKey = (jobId?: string,) => { - return [ - `/v1/job/${jobId}` - ] as const; - } - - -export const getGetJobQueryOptions = <TData = Awaited<ReturnType<typeof getJob>>, TError = void | HTTPValidationError>(jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetJobQueryOptions = < + TData = Awaited<ReturnType<typeof getJob>>, + TError = void | HTTPValidationError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(jobId); - const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(jobId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getJob>>> = ({ + signal, + }) => getJob(jobId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!jobId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getJob>>> = ({ signal }) => getJob(jobId, requestOptions, signal); +export type GetJobQueryResult = NonNullable<Awaited<ReturnType<typeof getJob>>>; +export type GetJobQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(jobId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetJobQueryResult = NonNullable<Awaited<ReturnType<typeof getJob>>> -export type GetJobQueryError = void | HTTPValidationError - - -export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = void | HTTPValidationError>( - jobId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>> & Pick< +export function useGetJob< + TData = Awaited<ReturnType<typeof getJob>>, + TError = void | HTTPValidationError, +>( + jobId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getJob>>, TError, Awaited<ReturnType<typeof getJob>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = void | HTTPValidationError>( - jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetJob< + TData = Awaited<ReturnType<typeof getJob>>, + TError = void | HTTPValidationError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getJob>>, TError, Awaited<ReturnType<typeof getJob>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = void | HTTPValidationError>( - jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetJob< + TData = Awaited<ReturnType<typeof getJob>>, + TError = void | HTTPValidationError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 잡 상태 조회(폴링) */ -export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = void | HTTPValidationError>( - jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetJob< + TData = Awaited<ReturnType<typeof getJob>>, + TError = void | HTTPValidationError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetJobQueryOptions(jobId, options); - const queryOptions = getGetJobQueryOptions(jobId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * max_attempts 를 소진해 dead-letter 로 간 잡을 다시 대기로 돌린다. 내부 운영 전용. * @summary DEAD 잡 재큐 */ export const requeueJob = ( - jobId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + jobId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResJob>( - {url: `/v1/job/${jobId}/requeue`, method: 'POST', signal - }, - options); - } - + return customFetch<ResJob>( + { url: `/v1/job/${jobId}/requeue`, method: "POST", signal }, + options, + ); +}; +export const getRequeueJobMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof requeueJob>>, + TError, + { jobId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof requeueJob>>, + TError, + { jobId: string }, + TContext +> => { + const mutationKey = ["requeueJob"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getRequeueJobMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof requeueJob>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof requeueJob>>, TError,{jobId: string}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof requeueJob>>, + { jobId: string } + > = (props) => { + const { jobId } = props ?? {}; -const mutationKey = ['requeueJob']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return requeueJob(jobId, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type RequeueJobMutationResult = NonNullable< + Awaited<ReturnType<typeof requeueJob>> +>; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof requeueJob>>, {jobId: string}> = (props) => { - const {jobId} = props ?? {}; +export type RequeueJobMutationError = void | HTTPValidationError; - return requeueJob(jobId,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type RequeueJobMutationResult = NonNullable<Awaited<ReturnType<typeof requeueJob>>> - - export type RequeueJobMutationError = void | HTTPValidationError - - /** +/** * @summary DEAD 잡 재큐 */ -export const useRequeueJob = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof requeueJob>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof requeueJob>>, - TError, - {jobId: string}, - TContext - > => { +export const useRequeueJob = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof requeueJob>>, + TError, + { jobId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof requeueJob>>, + TError, + { jobId: string }, + TContext +> => { + const mutationOptions = getRequeueJobMutationOptions(options); - const mutationOptions = getRequeueJobMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - \ No newline at end of file + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/local-content/local-content.ts b/solution/frontend/src/api/generated/local-content/local-content.ts index 60bfdc2..0ed973c 100644 --- a/solution/frontend/src/api/generated/local-content/local-content.ts +++ b/solution/frontend/src/api/generated/local-content/local-content.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,465 +17,1059 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { + GetGuideParams, GetWeatherParams, HTTPValidationError, ListContentsParams, + ReqHidePlaceContent, ReqPublishLocalContent, - ReqSyncFestivals, ReqUpdateLocalContent, ResLocalContentList, - ResSyncFestivals, - ResWeather -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResLocalGuide, + ResPlaceContentList, + ResSyncPlace, + ResWeather, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; +/** + * @summary 업장 주변정보 목록 (숨김 포함) + */ +export const listPlaceContents = ( + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResPlaceContentList>( + { url: `/v1/admin/local-content/place/${placeId}`, method: "GET", signal }, + options, + ); +}; +export const getListPlaceContentsQueryKey = (placeId?: string) => { + return [`/v1/admin/local-content/place/${placeId}`] as const; +}; +export const getListPlaceContentsQueryOptions = < + TData = Awaited<ReturnType<typeof listPlaceContents>>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListPlaceContentsQueryKey(placeId); + + const queryFn: QueryFunction< + Awaited<ReturnType<typeof listPlaceContents>> + > = ({ signal }) => listPlaceContents(placeId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; + +export type ListPlaceContentsQueryResult = NonNullable< + Awaited<ReturnType<typeof listPlaceContents>> +>; +export type ListPlaceContentsQueryError = HTTPValidationError; + +export function useListPlaceContents< + TData = Awaited<ReturnType<typeof listPlaceContents>>, + TError = HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + Awaited<ReturnType<typeof listPlaceContents>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListPlaceContents< + TData = Awaited<ReturnType<typeof listPlaceContents>>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + Awaited<ReturnType<typeof listPlaceContents>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListPlaceContents< + TData = Awaited<ReturnType<typeof listPlaceContents>>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +/** + * @summary 업장 주변정보 목록 (숨김 포함) + */ + +export function useListPlaceContents< + TData = Awaited<ReturnType<typeof listPlaceContents>>, + TError = HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof listPlaceContents>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListPlaceContentsQueryOptions(placeId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * 빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다. + * @summary 업장 주변정보 재수집 (TourAPI 반경) + */ +export const syncPlace = ( + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResSyncPlace>( + { + url: `/v1/admin/local-content/place/${placeId}/sync`, + method: "POST", + signal, + }, + options, + ); +}; + +export const getSyncPlaceMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof syncPlace>>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof syncPlace>>, + TError, + { placeId: string }, + TContext +> => { + const mutationKey = ["syncPlace"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof syncPlace>>, + { placeId: string } + > = (props) => { + const { placeId } = props ?? {}; + + return syncPlace(placeId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SyncPlaceMutationResult = NonNullable< + Awaited<ReturnType<typeof syncPlace>> +>; + +export type SyncPlaceMutationError = HTTPValidationError; + +/** + * @summary 업장 주변정보 재수집 (TourAPI 반경) + */ +export const useSyncPlace = <TError = HTTPValidationError, TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof syncPlace>>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof syncPlace>>, + TError, + { placeId: string }, + TContext +> => { + const mutationOptions = getSyncPlaceMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * 숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다. + * @summary 주변정보 숨김/해제 + */ +export const hidePlaceContent = ( + placeContentId: string, + reqHidePlaceContent: ReqHidePlaceContent, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResPlaceContentList>( + { + url: `/v1/admin/local-content/place-content/${placeContentId}/hide`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqHidePlaceContent, + signal, + }, + options, + ); +}; + +export const getHidePlaceContentMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof hidePlaceContent>>, + TError, + { placeContentId: string; data: ReqHidePlaceContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof hidePlaceContent>>, + TError, + { placeContentId: string; data: ReqHidePlaceContent }, + TContext +> => { + const mutationKey = ["hidePlaceContent"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof hidePlaceContent>>, + { placeContentId: string; data: ReqHidePlaceContent } + > = (props) => { + const { placeContentId, data } = props ?? {}; + + return hidePlaceContent(placeContentId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type HidePlaceContentMutationResult = NonNullable< + Awaited<ReturnType<typeof hidePlaceContent>> +>; +export type HidePlaceContentMutationBody = ReqHidePlaceContent; +export type HidePlaceContentMutationError = HTTPValidationError; + +/** + * @summary 주변정보 숨김/해제 + */ +export const useHidePlaceContent = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof hidePlaceContent>>, + TError, + { placeContentId: string; data: ReqHidePlaceContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof hidePlaceContent>>, + TError, + { placeContentId: string; data: ReqHidePlaceContent }, + TContext +> => { + const mutationOptions = getHidePlaceContentMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * @summary List Contents */ export const listContents = ( - params?: ListContentsParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params?: ListContentsParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLocalContentList>( - {url: `/v1/admin/local-content`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResLocalContentList>( + { url: `/v1/admin/local-content`, method: "GET", params, signal }, + options, + ); +}; +export const getListContentsQueryKey = (params?: ListContentsParams) => { + return [`/v1/admin/local-content`, ...(params ? [params] : [])] as const; +}; - -export const getListContentsQueryKey = (params?: ListContentsParams,) => { - return [ - `/v1/admin/local-content`, ...(params ? [params]: []) - ] as const; - } - - -export const getListContentsQueryOptions = <TData = Awaited<ReturnType<typeof listContents>>, TError = HTTPValidationError>(params?: ListContentsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListContentsQueryOptions = < + TData = Awaited<ReturnType<typeof listContents>>, + TError = HTTPValidationError, +>( + params?: ListContentsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListContentsQueryKey(params); - const queryKey = queryOptions?.queryKey ?? getListContentsQueryKey(params); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listContents>>> = ({ + signal, + }) => listContents(params, requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof listContents>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listContents>>> = ({ signal }) => listContents(params, requestOptions, signal); +export type ListContentsQueryResult = NonNullable< + Awaited<ReturnType<typeof listContents>> +>; +export type ListContentsQueryError = HTTPValidationError; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListContentsQueryResult = NonNullable<Awaited<ReturnType<typeof listContents>>> -export type ListContentsQueryError = HTTPValidationError - - -export function useListContents<TData = Awaited<ReturnType<typeof listContents>>, TError = HTTPValidationError>( - params: undefined | ListContentsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData>> & Pick< +export function useListContents< + TData = Awaited<ReturnType<typeof listContents>>, + TError = HTTPValidationError, +>( + params: undefined | ListContentsParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listContents>>, TError, Awaited<ReturnType<typeof listContents>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListContents<TData = Awaited<ReturnType<typeof listContents>>, TError = HTTPValidationError>( - params?: ListContentsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListContents< + TData = Awaited<ReturnType<typeof listContents>>, + TError = HTTPValidationError, +>( + params?: ListContentsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listContents>>, TError, Awaited<ReturnType<typeof listContents>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListContents<TData = Awaited<ReturnType<typeof listContents>>, TError = HTTPValidationError>( - params?: ListContentsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListContents< + TData = Awaited<ReturnType<typeof listContents>>, + TError = HTTPValidationError, +>( + params?: ListContentsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary List Contents */ -export function useListContents<TData = Awaited<ReturnType<typeof listContents>>, TError = HTTPValidationError>( - params?: ListContentsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListContents< + TData = Awaited<ReturnType<typeof listContents>>, + TError = HTTPValidationError, +>( + params?: ListContentsParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listContents>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListContentsQueryOptions(params, options); - const queryOptions = getListContentsQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** - * @summary Sync Festivals - */ -export const syncFestivals = ( - reqSyncFestivals: ReqSyncFestivals, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal -) => { - - - return customFetch<ResSyncFestivals>( - {url: `/v1/admin/local-content/sync-festivals`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSyncFestivals, signal - }, - options); - } - - - -export const getSyncFestivalsMutationOptions = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof syncFestivals>>, TError,{data: ReqSyncFestivals}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof syncFestivals>>, TError,{data: ReqSyncFestivals}, TContext> => { - -const mutationKey = ['syncFestivals']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction<Awaited<ReturnType<typeof syncFestivals>>, {data: ReqSyncFestivals}> = (props) => { - const {data} = props ?? {}; - - return syncFestivals(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type SyncFestivalsMutationResult = NonNullable<Awaited<ReturnType<typeof syncFestivals>>> - export type SyncFestivalsMutationBody = ReqSyncFestivals - export type SyncFestivalsMutationError = HTTPValidationError - - /** - * @summary Sync Festivals - */ -export const useSyncFestivals = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof syncFestivals>>, TError,{data: ReqSyncFestivals}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof syncFestivals>>, - TError, - {data: ReqSyncFestivals}, - TContext - > => { - - const mutationOptions = getSyncFestivalsMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** * @summary Publish */ export const publish = ( - reqPublishLocalContent: ReqPublishLocalContent, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + reqPublishLocalContent: ReqPublishLocalContent, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLocalContentList>( - {url: `/v1/admin/local-content/publish`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqPublishLocalContent, signal + return customFetch<ResLocalContentList>( + { + url: `/v1/admin/local-content/publish`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqPublishLocalContent, + signal, }, - options); - } - + options, + ); +}; +export const getPublishMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof publish>>, + TError, + { data: ReqPublishLocalContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof publish>>, + TError, + { data: ReqPublishLocalContent }, + TContext +> => { + const mutationKey = ["publish"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getPublishMutationOptions = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof publish>>, TError,{data: ReqPublishLocalContent}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof publish>>, TError,{data: ReqPublishLocalContent}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof publish>>, + { data: ReqPublishLocalContent } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['publish']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return publish(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type PublishMutationResult = NonNullable< + Awaited<ReturnType<typeof publish>> +>; +export type PublishMutationBody = ReqPublishLocalContent; +export type PublishMutationError = HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof publish>>, {data: ReqPublishLocalContent}> = (props) => { - const {data} = props ?? {}; - - return publish(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type PublishMutationResult = NonNullable<Awaited<ReturnType<typeof publish>>> - export type PublishMutationBody = ReqPublishLocalContent - export type PublishMutationError = HTTPValidationError - - /** +/** * @summary Publish */ -export const usePublish = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof publish>>, TError,{data: ReqPublishLocalContent}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof publish>>, - TError, - {data: ReqPublishLocalContent}, - TContext - > => { +export const usePublish = <TError = HTTPValidationError, TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof publish>>, + TError, + { data: ReqPublishLocalContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof publish>>, + TError, + { data: ReqPublishLocalContent }, + TContext +> => { + const mutationOptions = getPublishMutationOptions(options); - const mutationOptions = getPublishMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary Update Content */ export const updateContent = ( - contentId: string, - reqUpdateLocalContent: ReqUpdateLocalContent, - options?: SecondParameter<typeof customFetch>,) => { - - - return customFetch<ResLocalContentList>( - {url: `/v1/admin/local-content/${contentId}`, method: 'PATCH', - headers: {'Content-Type': 'application/json', }, - data: reqUpdateLocalContent + contentId: string, + reqUpdateLocalContent: ReqUpdateLocalContent, + options?: SecondParameter<typeof customFetch>, +) => { + return customFetch<ResLocalContentList>( + { + url: `/v1/admin/local-content/${contentId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateLocalContent, }, - options); - } - + options, + ); +}; +export const getUpdateContentMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updateContent>>, + TError, + { contentId: string; data: ReqUpdateLocalContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof updateContent>>, + TError, + { contentId: string; data: ReqUpdateLocalContent }, + TContext +> => { + const mutationKey = ["updateContent"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getUpdateContentMutationOptions = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateContent>>, TError,{contentId: string;data: ReqUpdateLocalContent}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof updateContent>>, TError,{contentId: string;data: ReqUpdateLocalContent}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof updateContent>>, + { contentId: string; data: ReqUpdateLocalContent } + > = (props) => { + const { contentId, data } = props ?? {}; -const mutationKey = ['updateContent']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return updateContent(contentId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type UpdateContentMutationResult = NonNullable< + Awaited<ReturnType<typeof updateContent>> +>; +export type UpdateContentMutationBody = ReqUpdateLocalContent; +export type UpdateContentMutationError = HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateContent>>, {contentId: string;data: ReqUpdateLocalContent}> = (props) => { - const {contentId,data} = props ?? {}; - - return updateContent(contentId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type UpdateContentMutationResult = NonNullable<Awaited<ReturnType<typeof updateContent>>> - export type UpdateContentMutationBody = ReqUpdateLocalContent - export type UpdateContentMutationError = HTTPValidationError - - /** +/** * @summary Update Content */ -export const useUpdateContent = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateContent>>, TError,{contentId: string;data: ReqUpdateLocalContent}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof updateContent>>, - TError, - {contentId: string;data: ReqUpdateLocalContent}, - TContext - > => { +export const useUpdateContent = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updateContent>>, + TError, + { contentId: string; data: ReqUpdateLocalContent }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof updateContent>>, + TError, + { contentId: string; data: ReqUpdateLocalContent }, + TContext +> => { + const mutationOptions = getUpdateContentMutationOptions(options); - const mutationOptions = getUpdateContentMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary End Content */ export const endContent = ( - contentId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + contentId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLocalContentList>( - {url: `/v1/admin/local-content/${contentId}/end`, method: 'POST', signal - }, - options); - } - + return customFetch<ResLocalContentList>( + { url: `/v1/admin/local-content/${contentId}/end`, method: "POST", signal }, + options, + ); +}; +export const getEndContentMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof endContent>>, + TError, + { contentId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof endContent>>, + TError, + { contentId: string }, + TContext +> => { + const mutationKey = ["endContent"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getEndContentMutationOptions = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof endContent>>, TError,{contentId: string}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof endContent>>, TError,{contentId: string}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof endContent>>, + { contentId: string } + > = (props) => { + const { contentId } = props ?? {}; -const mutationKey = ['endContent']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return endContent(contentId, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type EndContentMutationResult = NonNullable< + Awaited<ReturnType<typeof endContent>> +>; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof endContent>>, {contentId: string}> = (props) => { - const {contentId} = props ?? {}; +export type EndContentMutationError = HTTPValidationError; - return endContent(contentId,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type EndContentMutationResult = NonNullable<Awaited<ReturnType<typeof endContent>>> - - export type EndContentMutationError = HTTPValidationError - - /** +/** * @summary End Content */ -export const useEndContent = <TError = HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof endContent>>, TError,{contentId: string}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof endContent>>, - TError, - {contentId: string}, - TContext - > => { +export const useEndContent = <TError = HTTPValidationError, TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof endContent>>, + TError, + { contentId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof endContent>>, + TError, + { contentId: string }, + TContext +> => { + const mutationOptions = getEndContentMutationOptions(options); - const mutationOptions = getEndContentMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary 현재 날씨 조회 (cache-aside) */ export const getWeather = ( - params: GetWeatherParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params: GetWeatherParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResWeather>( - {url: `/v1/local/weather`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResWeather>( + { url: `/v1/local/weather`, method: "GET", params, signal }, + options, + ); +}; +export const getGetWeatherQueryKey = (params?: GetWeatherParams) => { + return [`/v1/local/weather`, ...(params ? [params] : [])] as const; +}; - -export const getGetWeatherQueryKey = (params?: GetWeatherParams,) => { - return [ - `/v1/local/weather`, ...(params ? [params]: []) - ] as const; - } - - -export const getGetWeatherQueryOptions = <TData = Awaited<ReturnType<typeof getWeather>>, TError = HTTPValidationError>(params: GetWeatherParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetWeatherQueryOptions = < + TData = Awaited<ReturnType<typeof getWeather>>, + TError = HTTPValidationError, +>( + params: GetWeatherParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetWeatherQueryKey(params); - const queryKey = queryOptions?.queryKey ?? getGetWeatherQueryKey(params); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getWeather>>> = ({ + signal, + }) => getWeather(params, requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof getWeather>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getWeather>>> = ({ signal }) => getWeather(params, requestOptions, signal); +export type GetWeatherQueryResult = NonNullable< + Awaited<ReturnType<typeof getWeather>> +>; +export type GetWeatherQueryError = HTTPValidationError; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetWeatherQueryResult = NonNullable<Awaited<ReturnType<typeof getWeather>>> -export type GetWeatherQueryError = HTTPValidationError - - -export function useGetWeather<TData = Awaited<ReturnType<typeof getWeather>>, TError = HTTPValidationError>( - params: GetWeatherParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData>> & Pick< +export function useGetWeather< + TData = Awaited<ReturnType<typeof getWeather>>, + TError = HTTPValidationError, +>( + params: GetWeatherParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getWeather>>, TError, Awaited<ReturnType<typeof getWeather>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetWeather<TData = Awaited<ReturnType<typeof getWeather>>, TError = HTTPValidationError>( - params: GetWeatherParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetWeather< + TData = Awaited<ReturnType<typeof getWeather>>, + TError = HTTPValidationError, +>( + params: GetWeatherParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getWeather>>, TError, Awaited<ReturnType<typeof getWeather>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetWeather<TData = Awaited<ReturnType<typeof getWeather>>, TError = HTTPValidationError>( - params: GetWeatherParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetWeather< + TData = Awaited<ReturnType<typeof getWeather>>, + TError = HTTPValidationError, +>( + params: GetWeatherParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 현재 날씨 조회 (cache-aside) */ -export function useGetWeather<TData = Awaited<ReturnType<typeof getWeather>>, TError = HTTPValidationError>( - params: GetWeatherParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetWeather< + TData = Awaited<ReturnType<typeof getWeather>>, + TError = HTTPValidationError, +>( + params: GetWeatherParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getWeather>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetWeatherQueryOptions(params, options); - const queryOptions = getGetWeatherQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } +/** + * 날씨와 같은 공개 조회다 — 운영자가 숨기지 않은 공공데이터만 나가므로 인증을 요구하지 않는다. + * @summary 업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용 + */ +export const getGuide = ( + params: GetGuideParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResLocalGuide>( + { url: `/v1/local/guide`, method: "GET", params, signal }, + options, + ); +}; +export const getGetGuideQueryKey = (params?: GetGuideParams) => { + return [`/v1/local/guide`, ...(params ? [params] : [])] as const; +}; +export const getGetGuideQueryOptions = < + TData = Awaited<ReturnType<typeof getGuide>>, + TError = HTTPValidationError, +>( + params: GetGuideParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getGuide>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetGuideQueryKey(params); + + const queryFn: QueryFunction<Awaited<ReturnType<typeof getGuide>>> = ({ + signal, + }) => getGuide(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof getGuide>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; + +export type GetGuideQueryResult = NonNullable< + Awaited<ReturnType<typeof getGuide>> +>; +export type GetGuideQueryError = HTTPValidationError; + +export function useGetGuide< + TData = Awaited<ReturnType<typeof getGuide>>, + TError = HTTPValidationError, +>( + params: GetGuideParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getGuide>>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited<ReturnType<typeof getGuide>>, + TError, + Awaited<ReturnType<typeof getGuide>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetGuide< + TData = Awaited<ReturnType<typeof getGuide>>, + TError = HTTPValidationError, +>( + params: GetGuideParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getGuide>>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited<ReturnType<typeof getGuide>>, + TError, + Awaited<ReturnType<typeof getGuide>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetGuide< + TData = Awaited<ReturnType<typeof getGuide>>, + TError = HTTPValidationError, +>( + params: GetGuideParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getGuide>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +/** + * @summary 업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용 + */ + +export function useGetGuide< + TData = Awaited<ReturnType<typeof getGuide>>, + TError = HTTPValidationError, +>( + params: GetGuideParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getGuide>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetGuideQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/solution/frontend/src/api/generated/media/media.ts b/solution/frontend/src/api/generated/media/media.ts index b9aa6d9..87e5077 100644 --- a/solution/frontend/src/api/generated/media/media.ts +++ b/solution/frontend/src/api/generated/media/media.ts @@ -4,9 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useQuery -} from '@tanstack/react-query'; +import { useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -16,120 +14,174 @@ import type { QueryKey, UndefinedInitialDataOptions, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, ListMediaParams, - ResMediaList -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResMediaList, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * publishable_only=true 면 ★ 사이트에 실제로 나갈 수 있는 것(승인 + alt 있음)만 — 빌드 스냅샷과 같은 기준이다. unit_id 를 주면 그 객실·메뉴 사진만. ★ 응답에는 항상 source_type(owner/crawl)과 origin_url 이 실린다 — 크롤링 이미지 재게시 권리가 미결이라(docs/DECISIONS.md 1-2) 결론에 따라 CRAWL 을 통째로 걸러내야 하고, 화면이 출처를 모르면 그 판단을 못 한다. * @summary 사진 목록 */ export const listMedia = ( - placeId: string, - params?: ListMediaParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params?: ListMediaParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResMediaList>( - {url: `/v1/place/${placeId}/media/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResMediaList>( + { url: `/v1/place/${placeId}/media/list`, method: "GET", params, signal }, + options, + ); +}; - - -export const getListMediaQueryKey = (placeId?: string, - params?: ListMediaParams,) => { - return [ - `/v1/place/${placeId}/media/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListMediaQueryOptions = <TData = Awaited<ReturnType<typeof listMedia>>, TError = void | HTTPValidationError>(placeId: string, - params?: ListMediaParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListMediaQueryKey = ( + placeId?: string, + params?: ListMediaParams, ) => { + return [ + `/v1/place/${placeId}/media/list`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getListMediaQueryOptions = < + TData = Awaited<ReturnType<typeof listMedia>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListMediaParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListMediaQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getListMediaQueryKey(placeId, params); - + const queryFn: QueryFunction<Awaited<ReturnType<typeof listMedia>>> = ({ + signal, + }) => listMedia(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof listMedia>>> = ({ signal }) => listMedia(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - +export type ListMediaQueryResult = NonNullable< + Awaited<ReturnType<typeof listMedia>> +>; +export type ListMediaQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListMediaQueryResult = NonNullable<Awaited<ReturnType<typeof listMedia>>> -export type ListMediaQueryError = void | HTTPValidationError - - -export function useListMedia<TData = Awaited<ReturnType<typeof listMedia>>, TError = void | HTTPValidationError>( - placeId: string, - params: undefined | ListMediaParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData>> & Pick< +export function useListMedia< + TData = Awaited<ReturnType<typeof listMedia>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: undefined | ListMediaParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listMedia>>, TError, Awaited<ReturnType<typeof listMedia>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListMedia<TData = Awaited<ReturnType<typeof listMedia>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListMediaParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListMedia< + TData = Awaited<ReturnType<typeof listMedia>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListMediaParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listMedia>>, TError, Awaited<ReturnType<typeof listMedia>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListMedia<TData = Awaited<ReturnType<typeof listMedia>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListMediaParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListMedia< + TData = Awaited<ReturnType<typeof listMedia>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListMediaParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 사진 목록 */ -export function useListMedia<TData = Awaited<ReturnType<typeof listMedia>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListMediaParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListMedia< + TData = Awaited<ReturnType<typeof listMedia>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListMediaParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMedia>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListMediaQueryOptions(placeId, params, options); - const queryOptions = getListMediaQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - - diff --git a/solution/frontend/src/api/generated/model/auditCheckData.ts b/solution/frontend/src/api/generated/model/auditCheckData.ts index 7e9def8..8deaec1 100644 --- a/solution/frontend/src/api/generated/model/auditCheckData.ts +++ b/solution/frontend/src/api/generated/model/auditCheckData.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { AuditCheckDataRecommendation } from './auditCheckDataRecommendation'; +import type { AuditCheckDataRecommendation } from "./auditCheckDataRecommendation"; export interface AuditCheckData { id: string; diff --git a/solution/frontend/src/api/generated/model/authProvider.ts b/solution/frontend/src/api/generated/model/authProvider.ts index da7067b..5f73a5b 100644 --- a/solution/frontend/src/api/generated/model/authProvider.ts +++ b/solution/frontend/src/api/generated/model/authProvider.ts @@ -12,8 +12,7 @@ 이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면 남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절. */ -export type AuthProvider = typeof AuthProvider[keyof typeof AuthProvider]; - +export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const AuthProvider = { diff --git a/solution/frontend/src/api/generated/model/buildStatus.ts b/solution/frontend/src/api/generated/model/buildStatus.ts index f04faeb..1d296cb 100644 --- a/solution/frontend/src/api/generated/model/buildStatus.ts +++ b/solution/frontend/src/api/generated/model/buildStatus.ts @@ -8,8 +8,7 @@ /** * site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다. */ -export type BuildStatus = typeof BuildStatus[keyof typeof BuildStatus]; - +export type BuildStatus = (typeof BuildStatus)[keyof typeof BuildStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const BuildStatus = { diff --git a/solution/frontend/src/api/generated/model/checkSlugParams.ts b/solution/frontend/src/api/generated/model/checkSlugParams.ts index 119448b..3b1f10c 100644 --- a/solution/frontend/src/api/generated/model/checkSlugParams.ts +++ b/solution/frontend/src/api/generated/model/checkSlugParams.ts @@ -6,8 +6,8 @@ */ export type CheckSlugParams = { -/** - * 쓰고 싶은 주소(예: doflo) - */ -slug: string; + /** + * 쓰고 싶은 주소(예: doflo) + */ + slug: string; }; diff --git a/solution/frontend/src/api/generated/model/errorInfo.ts b/solution/frontend/src/api/generated/model/errorInfo.ts index 5db7ee8..ae2ca0a 100644 --- a/solution/frontend/src/api/generated/model/errorInfo.ts +++ b/solution/frontend/src/api/generated/model/errorInfo.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfoSuccess } from './errorInfoSuccess'; -import type { ErrorInfoCode } from './errorInfoCode'; -import type { ErrorInfoDesc } from './errorInfoDesc'; +import type { ErrorInfoSuccess } from "./errorInfoSuccess"; +import type { ErrorInfoCode } from "./errorInfoCode"; +import type { ErrorInfoDesc } from "./errorInfoDesc"; /** * 모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다. diff --git a/solution/frontend/src/api/generated/model/externalPlaceSource.ts b/solution/frontend/src/api/generated/model/externalPlaceSource.ts index 756cc7d..bbf6c1a 100644 --- a/solution/frontend/src/api/generated/model/externalPlaceSource.ts +++ b/solution/frontend/src/api/generated/model/externalPlaceSource.ts @@ -11,8 +11,8 @@ 카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다. 네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다. */ -export type ExternalPlaceSource = typeof ExternalPlaceSource[keyof typeof ExternalPlaceSource]; - +export type ExternalPlaceSource = + (typeof ExternalPlaceSource)[keyof typeof ExternalPlaceSource]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const ExternalPlaceSource = { diff --git a/solution/frontend/src/api/generated/model/factData.ts b/solution/frontend/src/api/generated/model/factData.ts index fd6ca73..d51db8e 100644 --- a/solution/frontend/src/api/generated/model/factData.ts +++ b/solution/frontend/src/api/generated/model/factData.ts @@ -4,16 +4,17 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FactDataUnitId } from './factDataUnitId'; -import type { FactDataValue } from './factDataValue'; -import type { FactDataUnit } from './factDataUnit'; -import type { SourceType } from './sourceType'; -import type { FactDataSourceUrl } from './factDataSourceUrl'; -import type { FactDataCollectedAt } from './factDataCollectedAt'; -import type { FactDataVerifiedBy } from './factDataVerifiedBy'; -import type { FactDataVerifiedAt } from './factDataVerifiedAt'; -import type { FactStatus } from './factStatus'; -import type { FactDataExpiresAt } from './factDataExpiresAt'; +import type { FactDataUnitId } from "./factDataUnitId"; +import type { FactDataValue } from "./factDataValue"; +import type { FactDataSummary } from "./factDataSummary"; +import type { FactDataUnit } from "./factDataUnit"; +import type { SourceType } from "./sourceType"; +import type { FactDataSourceUrl } from "./factDataSourceUrl"; +import type { FactDataCollectedAt } from "./factDataCollectedAt"; +import type { FactDataVerifiedBy } from "./factDataVerifiedBy"; +import type { FactDataVerifiedAt } from "./factDataVerifiedAt"; +import type { FactStatus } from "./factStatus"; +import type { FactDataExpiresAt } from "./factDataExpiresAt"; export interface FactData { fact_id: string; @@ -21,6 +22,7 @@ export interface FactData { unit_id?: FactDataUnitId; key: string; value?: FactDataValue; + summary?: FactDataSummary; unit?: FactDataUnit; source_type: SourceType; source_url?: FactDataSourceUrl; diff --git a/solution/frontend/src/api/generated/model/resSyncFestivalsMsg.ts b/solution/frontend/src/api/generated/model/factDataSummary.ts similarity index 69% rename from solution/frontend/src/api/generated/model/resSyncFestivalsMsg.ts rename to solution/frontend/src/api/generated/model/factDataSummary.ts index be86451..b24231c 100644 --- a/solution/frontend/src/api/generated/model/resSyncFestivalsMsg.ts +++ b/solution/frontend/src/api/generated/model/factDataSummary.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResSyncFestivalsMsg = string | null; +export type FactDataSummary = string | null; diff --git a/solution/frontend/src/api/generated/model/factStatus.ts b/solution/frontend/src/api/generated/model/factStatus.ts index 0c78010..9e9d937 100644 --- a/solution/frontend/src/api/generated/model/factStatus.ts +++ b/solution/frontend/src/api/generated/model/factStatus.ts @@ -9,8 +9,7 @@ * facts.status / faqs.status / routes.status 공용 검증 상태. ★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES). */ -export type FactStatus = typeof FactStatus[keyof typeof FactStatus]; - +export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const FactStatus = { diff --git a/solution/frontend/src/api/generated/model/factWriteOutcome.ts b/solution/frontend/src/api/generated/model/factWriteOutcome.ts index d1791cb..2a19d1a 100644 --- a/solution/frontend/src/api/generated/model/factWriteOutcome.ts +++ b/solution/frontend/src/api/generated/model/factWriteOutcome.ts @@ -9,8 +9,8 @@ * fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 — 특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다. */ -export type FactWriteOutcome = typeof FactWriteOutcome[keyof typeof FactWriteOutcome]; - +export type FactWriteOutcome = + (typeof FactWriteOutcome)[keyof typeof FactWriteOutcome]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const FactWriteOutcome = { diff --git a/solution/frontend/src/api/generated/model/faqData.ts b/solution/frontend/src/api/generated/model/faqData.ts index f196959..5cf723a 100644 --- a/solution/frontend/src/api/generated/model/faqData.ts +++ b/solution/frontend/src/api/generated/model/faqData.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FaqDataSourceFactIds } from './faqDataSourceFactIds'; -import type { SourceType } from './sourceType'; -import type { FactStatus } from './factStatus'; -import type { FaqDataCreatedAt } from './faqDataCreatedAt'; -import type { FaqDataUpdatedAt } from './faqDataUpdatedAt'; +import type { FaqDataSourceFactIds } from "./faqDataSourceFactIds"; +import type { SourceType } from "./sourceType"; +import type { FactStatus } from "./factStatus"; +import type { FaqDataCreatedAt } from "./faqDataCreatedAt"; +import type { FaqDataUpdatedAt } from "./faqDataUpdatedAt"; export interface FaqData { faq_id: string; diff --git a/solution/frontend/src/api/generated/model/fieldSpecData.ts b/solution/frontend/src/api/generated/model/fieldSpecData.ts index 9b6109c..a3f8295 100644 --- a/solution/frontend/src/api/generated/model/fieldSpecData.ts +++ b/solution/frontend/src/api/generated/model/fieldSpecData.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FieldSpecDataUnit } from './fieldSpecDataUnit'; +import type { FieldSpecDataUnit } from "./fieldSpecDataUnit"; /** * 업종 스키마의 필드 정의 — 관리 화면이 입력 폼을 그리는 데 쓴다. diff --git a/solution/frontend/src/api/generated/model/reqSyncFestivalsAreaCode.ts b/solution/frontend/src/api/generated/model/getGuideParams.ts similarity index 67% rename from solution/frontend/src/api/generated/model/reqSyncFestivalsAreaCode.ts rename to solution/frontend/src/api/generated/model/getGuideParams.ts index aa1d905..cd9e1a6 100644 --- a/solution/frontend/src/api/generated/model/reqSyncFestivalsAreaCode.ts +++ b/solution/frontend/src/api/generated/model/getGuideParams.ts @@ -5,4 +5,6 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqSyncFestivalsAreaCode = string | null; +export type GetGuideParams = { + place_id: string; +}; diff --git a/solution/frontend/src/api/generated/model/getWeatherParams.ts b/solution/frontend/src/api/generated/model/getWeatherParams.ts index 022c056..cdd239d 100644 --- a/solution/frontend/src/api/generated/model/getWeatherParams.ts +++ b/solution/frontend/src/api/generated/model/getWeatherParams.ts @@ -6,19 +6,19 @@ */ export type GetWeatherParams = { -/** - * @minLength 1 - * @maxLength 10 - */ -region_code: string; -/** - * @minimum -90 - * @maximum 90 - */ -latitude: number; -/** - * @minimum -180 - * @maximum 180 - */ -longitude: number; + /** + * @minLength 1 + * @maxLength 10 + */ + region_code: string; + /** + * @minimum -90 + * @maximum 90 + */ + latitude: number; + /** + * @minimum -180 + * @maximum 180 + */ + longitude: number; }; diff --git a/solution/frontend/src/api/generated/model/hTTPValidationError.ts b/solution/frontend/src/api/generated/model/hTTPValidationError.ts index 37970db..b979e83 100644 --- a/solution/frontend/src/api/generated/model/hTTPValidationError.ts +++ b/solution/frontend/src/api/generated/model/hTTPValidationError.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ValidationError } from './validationError'; +import type { ValidationError } from "./validationError"; export interface HTTPValidationError { detail?: ValidationError[]; diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 2e38a7b..c0dc0d0 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -5,317 +5,333 @@ * OpenAPI spec version: 0.1.0 */ -export * from './auditCheckData'; -export * from './auditCheckDataRecommendation'; -export * from './authProvider'; -export * from './buildStatus'; -export * from './checkSlugParams'; -export * from './errorInfo'; -export * from './errorInfoCode'; -export * from './errorInfoDesc'; -export * from './errorInfoSuccess'; -export * from './externalPlaceSource'; -export * from './factData'; -export * from './factDataCollectedAt'; -export * from './factDataExpiresAt'; -export * from './factDataSourceUrl'; -export * from './factDataUnit'; -export * from './factDataUnitId'; -export * from './factDataValue'; -export * from './factDataVerifiedAt'; -export * from './factDataVerifiedBy'; -export * from './factStatus'; -export * from './factWriteOutcome'; -export * from './faqData'; -export * from './faqDataCreatedAt'; -export * from './faqDataSourceFactIds'; -export * from './faqDataUpdatedAt'; -export * from './fieldSpecData'; -export * from './fieldSpecDataUnit'; -export * from './getWeatherParams'; -export * from './hTTPValidationError'; -export * from './jobData'; -export * from './jobDataCreatedAt'; -export * from './jobDataLastError'; -export * from './jobDataResult'; -export * from './jobDataRunAfter'; -export * from './jobDataRunStartedAt'; -export * from './jobDataUpdatedAt'; -export * from './jobStatus'; -export * from './jobType'; -export * from './linkChannel'; -export * from './linkData'; -export * from './linkDataConfirmedAt'; -export * from './linkDataDiscoveredAt'; -export * from './linkDataTitle'; -export * from './listContentsParams'; -export * from './listFactsParams'; -export * from './listFaqsParams'; -export * from './listLinksParams'; -export * from './listMediaParams'; -export * from './listMySitesParams'; -export * from './listPlacesParams'; -export * from './listShowcaseParams'; -export * from './localContentData'; -export * from './localContentDataBody'; -export * from './localContentDataCollectedAt'; -export * from './localContentDataDisplayEndAt'; -export * from './localContentDataDisplayStartAt'; -export * from './localContentDataExternalId'; -export * from './localContentDataPublishedAt'; -export * from './localContentDataTitle'; -export * from './localContentStatus'; -export * from './localContentType'; -export * from './localSource'; -export * from './mediaData'; -export * from './mediaDataAltText'; -export * from './mediaDataCreatedAt'; -export * from './mediaDataHeight'; -export * from './mediaDataLabel'; -export * from './mediaDataOriginUrl'; -export * from './mediaDataSourceUrl'; -export * from './mediaDataUnitId'; -export * from './mediaDataVisionConfidence'; -export * from './mediaDataWidth'; -export * from './mediaStatus'; -export * from './mySiteData'; -export * from './mySiteDataCreatedAt'; -export * from './mySiteDataDomain'; -export * from './mySiteDataPublishedAt'; -export * from './mySiteDataThumbnailUrl'; -export * from './mySiteDataRoadAddress'; -export * from './mySiteDataSiteId'; -export * from './mySiteDataStatus'; -export * from './mySiteDataTemplateId'; -export * from './placeCandidate'; -export * from './placeCandidateAddress'; -export * from './placeCandidateCategoryName'; -export * from './placeCandidateExternalPlaceId'; -export * from './placeCandidateLatitude'; -export * from './placeCandidateLongitude'; -export * from './placeCandidateNaverPlaceUrl'; -export * from './placeCandidatePhone'; -export * from './placeCandidatePlaceUrl'; -export * from './placeCandidateRoadAddress'; -export * from './placeCategory'; -export * from './placeData'; -export * from './placeDataAddress'; -export * from './placeDataContentUpdatedAt'; -export * from './placeDataCreatedAt'; -export * from './placeDataExternalPlaceId'; -export * from './placeDataExternalSource'; -export * from './placeDataLatitude'; -export * from './placeDataLongitude'; -export * from './placeDataOwnerUserId'; -export * from './placeDataPhone'; -export * from './placeDataRegionCode'; -export * from './placeDataRoadAddress'; -export * from './placeDataVerifiedAt'; -export * from './placeSearchItem'; -export * from './placeSearchItemCategory'; -export * from './placeSearchItemCategoryName'; -export * from './placeSearchItemRoadAddress'; -export * from './placeStatus'; -export * from './publishAction'; -export * from './publishLogData'; -export * from './publishLogDataCreatedAt'; -export * from './publishLogDataDetail'; -export * from './publishLogDataRejectReason'; -export * from './publishRejectReason'; -export * from './publishResult'; -export * from './renderStatusData'; -export * from './renderStatusDataError'; -export * from './renderStatusDataRenderedAt'; -export * from './renderStatusDataRenderedVersion'; -export * from './reqCreateFaq'; -export * from './reqCreateFaqSortOrder'; -export * from './reqCreateLink'; -export * from './reqCreateLinkTitle'; -export * from './reqCreatePlace'; -export * from './reqCreatePlaceOwnerUserId'; -export * from './reqCreateUnit'; -export * from './reqExtractFacts'; -export * from './reqGoogleLogin'; -export * from './reqLogin'; -export * from './reqPublishLocalContent'; -export * from './reqSignup'; -export * from './reqSignupName'; -export * from './reqSiteSlug'; -export * from './reqSiteStatus'; -export * from './reqSiteTemplate'; -export * from './reqSiteTheme'; -export * from './reqSiteThemeTheme'; -export * from './reqStartBuild'; -export * from './reqStartCollect'; -export * from './reqStartCopy'; -export * from './reqStartVision'; -export * from './reqSyncFestivals'; -export * from './reqSyncFestivalsAreaCode'; -export * from './reqSyncFestivalsStartDate'; -export * from './reqTransitionFact'; -export * from './reqTransitionFactValue'; -export * from './reqTransitionFaq'; -export * from './reqTransitionFaqAnswer'; -export * from './reqTransitionFaqQuestion'; -export * from './reqUpdateLocalContent'; -export * from './reqUpdateLocalContentBody'; -export * from './reqUpdateLocalContentBodyAnyOf'; -export * from './reqUpdateLocalContentDisplayEndAt'; -export * from './reqUpdateLocalContentDisplayStartAt'; -export * from './reqUpdateLocalContentTitle'; -export * from './reqUpdateMe'; -export * from './reqUpdateMeContactNumber'; -export * from './reqUpdateMeEmail'; -export * from './reqUpdateMeName'; -export * from './reqUpdateMePassword'; -export * from './reqUpdatePlace'; -export * from './reqUpdatePlaceName'; -export * from './reqUpdatePlaceOwnerUserId'; -export * from './reqUpdatePlaceStatus'; -export * from './reqUpsertFact'; -export * from './reqUpsertFactExpiresAt'; -export * from './reqUpsertFactSourceUrl'; -export * from './reqUpsertFactUnitId'; -export * from './reqUpsertFactValue'; -export * from './reqVerifyPlace'; -export * from './reqVerifyPlaceAddress'; -export * from './reqVerifyPlaceByUrl'; -export * from './reqVerifyPlaceLatitude'; -export * from './reqVerifyPlaceLongitude'; -export * from './reqVerifyPlacePhone'; -export * from './reqVerifyPlacePlaceUrl'; -export * from './reqVerifyPlaceRegionCode'; -export * from './reqVerifyPlaceRoadAddress'; -export * from './resCategorySchema'; -export * from './resCategorySchemaCategory'; -export * from './resCategorySchemaLabel'; -export * from './resCategorySchemaMsg'; -export * from './resExtractFacts'; -export * from './resExtractFactsMsg'; -export * from './resExtractedFact'; -export * from './resExtractedFactReason'; -export * from './resExtractedFactUnitName'; -export * from './resFact'; -export * from './resFactFact'; -export * from './resFactList'; -export * from './resFactListMsg'; -export * from './resFactMsg'; -export * from './resFactOutcome'; -export * from './resFaq'; -export * from './resFaqFaq'; -export * from './resFaqList'; -export * from './resFaqListMsg'; -export * from './resFaqMsg'; -export * from './resJob'; -export * from './resJobJob'; -export * from './resJobMsg'; -export * from './resJobOps'; -export * from './resJobOpsMsg'; -export * from './resLink'; -export * from './resLinkLink'; -export * from './resLinkList'; -export * from './resLinkListMsg'; -export * from './resLinkMsg'; -export * from './resLocalContentList'; -export * from './resLocalContentListMsg'; -export * from './resLogin'; -export * from './resLoginMsg'; -export * from './resMe'; -export * from './resMeContactNumber'; -export * from './resMeEmail'; -export * from './resMeMsg'; -export * from './resMeName'; -export * from './resMediaList'; -export * from './resMediaListMsg'; -export * from './resMySites'; -export * from './resMySitesMsg'; -export * from './resPlace'; -export * from './resPlaceList'; -export * from './resPlaceListMsg'; -export * from './resPlaceMsg'; -export * from './resPlacePlace'; -export * from './resPlaceSearch'; -export * from './resPlaceSearchMsg'; -export * from './resPlaceSearchSource'; -export * from './resPublishLogs'; -export * from './resPublishLogsMsg'; -export * from './resRefreshToken'; -export * from './resRefreshTokenMsg'; -export * from './resSeoAudit'; -export * from './resSeoAuditMsg'; -export * from './resSeoAuditSummary'; -export * from './resSeoAuditVisibility'; -export * from './resShowcase'; -export * from './resShowcaseMsg'; -export * from './resSite'; -export * from './resSiteCurrentVersion'; -export * from './resSiteMsg'; -export * from './resSiteSite'; -export * from './resSiteSlug'; -export * from './resSiteSlugMsg'; -export * from './resSiteSlugReason'; -export * from './resSiteSlugSite'; -export * from './resSiteSlugSuggestion'; -export * from './resSiteVersions'; -export * from './resSiteVersionsMsg'; -export * from './resSlugCheck'; -export * from './resSlugCheckMsg'; -export * from './resSlugCheckReason'; -export * from './resSlugCheckSuggestion'; -export * from './resStartBuild'; -export * from './resStartBuildJobId'; -export * from './resStartBuildMsg'; -export * from './resStartBuildStatus'; -export * from './resStartCollect'; -export * from './resStartCollectJobId'; -export * from './resStartCollectMsg'; -export * from './resStartCollectStatus'; -export * from './resStartCopy'; -export * from './resStartCopyJobId'; -export * from './resStartCopyMsg'; -export * from './resStartCopyStatus'; -export * from './resStartVision'; -export * from './resStartVisionJobId'; -export * from './resStartVisionMsg'; -export * from './resStartVisionStatus'; -export * from './resSyncFestivals'; -export * from './resSyncFestivalsMsg'; -export * from './resUnit'; -export * from './resUnitList'; -export * from './resUnitListMsg'; -export * from './resUnitMsg'; -export * from './resUnitUnit'; -export * from './resVerifyCandidates'; -export * from './resVerifyCandidatesMsg'; -export * from './resVerifyCandidatesReason'; -export * from './resVerifyCandidatesSource'; -export * from './resWeather'; -export * from './resWeatherMsg'; -export * from './resWeatherWeather'; -export * from './searchPlacesPublicParams'; -export * from './showcaseItem'; -export * from './showcaseItemRegion'; -export * from './showcaseItemThumbnailUrl'; -export * from './siteData'; -export * from './siteDataCurrentVersionId'; -export * from './siteDataDomain'; -export * from './siteDataPublishedAt'; -export * from './siteDataTemplateId'; -export * from './siteDataTheme'; -export * from './siteDataThemeAnyOf'; -export * from './siteDataThumbnailUrl'; -export * from './siteStatus'; -export * from './siteVersionData'; -export * from './siteVersionDataBuildError'; -export * from './siteVersionDataBuiltAt'; -export * from './siteVersionDataCreatedAt'; -export * from './sourceType'; -export * from './unitData'; -export * from './userRole'; -export * from './validationError'; -export * from './validationErrorCtx'; -export * from './validationErrorLocItem'; -export * from './verifyCandidatesParams'; -export * from './weatherData'; -export * from './weatherDataTimezone'; -export * from './weatherDataWindSpeed'; \ No newline at end of file +export * from "./auditCheckData"; +export * from "./auditCheckDataRecommendation"; +export * from "./authProvider"; +export * from "./buildStatus"; +export * from "./checkSlugParams"; +export * from "./errorInfo"; +export * from "./errorInfoCode"; +export * from "./errorInfoDesc"; +export * from "./errorInfoSuccess"; +export * from "./externalPlaceSource"; +export * from "./factData"; +export * from "./factDataCollectedAt"; +export * from "./factDataExpiresAt"; +export * from "./factDataSourceUrl"; +export * from "./factDataSummary"; +export * from "./factDataUnit"; +export * from "./factDataUnitId"; +export * from "./factDataValue"; +export * from "./factDataVerifiedAt"; +export * from "./factDataVerifiedBy"; +export * from "./factStatus"; +export * from "./factWriteOutcome"; +export * from "./faqData"; +export * from "./faqDataCreatedAt"; +export * from "./faqDataSourceFactIds"; +export * from "./faqDataUpdatedAt"; +export * from "./fieldSpecData"; +export * from "./fieldSpecDataUnit"; +export * from "./getGuideParams"; +export * from "./getWeatherParams"; +export * from "./hTTPValidationError"; +export * from "./jobData"; +export * from "./jobDataCreatedAt"; +export * from "./jobDataLastError"; +export * from "./jobDataResult"; +export * from "./jobDataRunAfter"; +export * from "./jobDataRunStartedAt"; +export * from "./jobDataUpdatedAt"; +export * from "./jobStatus"; +export * from "./jobType"; +export * from "./linkChannel"; +export * from "./linkData"; +export * from "./linkDataConfirmedAt"; +export * from "./linkDataDiscoveredAt"; +export * from "./linkDataTitle"; +export * from "./listContentsParams"; +export * from "./listFactsParams"; +export * from "./listFaqsParams"; +export * from "./listLinksParams"; +export * from "./listMediaParams"; +export * from "./listMySitesParams"; +export * from "./listPlacesParams"; +export * from "./listShowcaseParams"; +export * from "./localContentData"; +export * from "./localContentDataBody"; +export * from "./localContentDataCollectedAt"; +export * from "./localContentDataDisplayEndAt"; +export * from "./localContentDataDisplayStartAt"; +export * from "./localContentDataExternalId"; +export * from "./localContentDataPublishedAt"; +export * from "./localContentDataTitle"; +export * from "./localContentStatus"; +export * from "./localContentType"; +export * from "./localSource"; +export * from "./mediaData"; +export * from "./mediaDataAltText"; +export * from "./mediaDataCreatedAt"; +export * from "./mediaDataHeight"; +export * from "./mediaDataLabel"; +export * from "./mediaDataOriginUrl"; +export * from "./mediaDataSourceUrl"; +export * from "./mediaDataUnitId"; +export * from "./mediaDataVisionConfidence"; +export * from "./mediaDataWidth"; +export * from "./mediaStatus"; +export * from "./mySiteData"; +export * from "./mySiteDataCreatedAt"; +export * from "./mySiteDataDomain"; +export * from "./mySiteDataPublishedAt"; +export * from "./mySiteDataRoadAddress"; +export * from "./mySiteDataSiteId"; +export * from "./mySiteDataStatus"; +export * from "./mySiteDataTemplateId"; +export * from "./mySiteDataThumbnailUrl"; +export * from "./placeCandidate"; +export * from "./placeCandidateAddress"; +export * from "./placeCandidateCategoryName"; +export * from "./placeCandidateExternalPlaceId"; +export * from "./placeCandidateLatitude"; +export * from "./placeCandidateLongitude"; +export * from "./placeCandidateNaverPlaceUrl"; +export * from "./placeCandidatePhone"; +export * from "./placeCandidatePlaceUrl"; +export * from "./placeCandidateRoadAddress"; +export * from "./placeCategory"; +export * from "./placeContentData"; +export * from "./placeContentDataBody"; +export * from "./placeContentDataCollectedAt"; +export * from "./placeContentDataDisplayEndAt"; +export * from "./placeData"; +export * from "./placeDataAddress"; +export * from "./placeDataContentUpdatedAt"; +export * from "./placeDataCreatedAt"; +export * from "./placeDataExternalPlaceId"; +export * from "./placeDataExternalSource"; +export * from "./placeDataLatitude"; +export * from "./placeDataLongitude"; +export * from "./placeDataOwnerUserId"; +export * from "./placeDataPhone"; +export * from "./placeDataRegionCode"; +export * from "./placeDataRoadAddress"; +export * from "./placeDataVerifiedAt"; +export * from "./placeSearchItem"; +export * from "./placeSearchItemCategory"; +export * from "./placeSearchItemCategoryName"; +export * from "./placeSearchItemNaverPlaceUrl"; +export * from "./placeSearchItemRoadAddress"; +export * from "./placeStatus"; +export * from "./publishAction"; +export * from "./publishLogData"; +export * from "./publishLogDataCreatedAt"; +export * from "./publishLogDataDetail"; +export * from "./publishLogDataRejectReason"; +export * from "./publishRejectReason"; +export * from "./publishResult"; +export * from "./relayParams"; +export * from "./renderStatusData"; +export * from "./renderStatusDataError"; +export * from "./renderStatusDataRenderedAt"; +export * from "./renderStatusDataRenderedVersion"; +export * from "./reqCreateFaq"; +export * from "./reqCreateFaqSortOrder"; +export * from "./reqCreateLink"; +export * from "./reqCreateLinkTitle"; +export * from "./reqCreatePlace"; +export * from "./reqCreateUnit"; +export * from "./reqExtractFacts"; +export * from "./reqGoogleLogin"; +export * from "./reqHidePlaceContent"; +export * from "./reqLogin"; +export * from "./reqPublishLocalContent"; +export * from "./reqRollback"; +export * from "./reqSignup"; +export * from "./reqSignupName"; +export * from "./reqSiteSlug"; +export * from "./reqSiteStatus"; +export * from "./reqSiteTemplate"; +export * from "./reqSiteTheme"; +export * from "./reqSiteThemeTheme"; +export * from "./reqStartBuild"; +export * from "./reqStartCollect"; +export * from "./reqStartCopy"; +export * from "./reqStartVision"; +export * from "./reqTransitionFact"; +export * from "./reqTransitionFactValue"; +export * from "./reqTransitionFaq"; +export * from "./reqTransitionFaqAnswer"; +export * from "./reqTransitionFaqQuestion"; +export * from "./reqUpdateLocalContent"; +export * from "./reqUpdateLocalContentBody"; +export * from "./reqUpdateLocalContentBodyAnyOf"; +export * from "./reqUpdateLocalContentDisplayEndAt"; +export * from "./reqUpdateLocalContentDisplayStartAt"; +export * from "./reqUpdateLocalContentTitle"; +export * from "./reqUpdateMe"; +export * from "./reqUpdateMeContactNumber"; +export * from "./reqUpdateMeEmail"; +export * from "./reqUpdateMeName"; +export * from "./reqUpdateMePassword"; +export * from "./reqUpdatePlace"; +export * from "./reqUpdatePlaceName"; +export * from "./reqUpdatePlaceStatus"; +export * from "./reqUpsertFact"; +export * from "./reqUpsertFactExpiresAt"; +export * from "./reqUpsertFactSourceUrl"; +export * from "./reqUpsertFactUnitId"; +export * from "./reqUpsertFactValue"; +export * from "./reqVerifyPlace"; +export * from "./reqVerifyPlaceAddress"; +export * from "./reqVerifyPlaceByUrl"; +export * from "./reqVerifyPlaceCategoryName"; +export * from "./reqVerifyPlaceLatitude"; +export * from "./reqVerifyPlaceLongitude"; +export * from "./reqVerifyPlacePhone"; +export * from "./reqVerifyPlacePlaceUrl"; +export * from "./reqVerifyPlaceRegionCode"; +export * from "./reqVerifyPlaceRoadAddress"; +export * from "./resCategorySchema"; +export * from "./resCategorySchemaCategory"; +export * from "./resCategorySchemaLabel"; +export * from "./resCategorySchemaMsg"; +export * from "./resExtractFacts"; +export * from "./resExtractFactsMsg"; +export * from "./resExtractedFact"; +export * from "./resExtractedFactReason"; +export * from "./resExtractedFactUnitName"; +export * from "./resFact"; +export * from "./resFactFact"; +export * from "./resFactList"; +export * from "./resFactListMsg"; +export * from "./resFactMsg"; +export * from "./resFactOutcome"; +export * from "./resFaq"; +export * from "./resFaqFaq"; +export * from "./resFaqList"; +export * from "./resFaqListMsg"; +export * from "./resFaqMsg"; +export * from "./resJob"; +export * from "./resJobJob"; +export * from "./resJobMsg"; +export * from "./resJobOps"; +export * from "./resJobOpsMsg"; +export * from "./resLink"; +export * from "./resLinkLink"; +export * from "./resLinkList"; +export * from "./resLinkListMsg"; +export * from "./resLinkMsg"; +export * from "./resLocalContentList"; +export * from "./resLocalContentListMsg"; +export * from "./resLocalGuide"; +export * from "./resLocalGuideAttractionsItem"; +export * from "./resLocalGuideCoursesItem"; +export * from "./resLocalGuideFestivalsItem"; +export * from "./resLocalGuideItinerariesItem"; +export * from "./resLocalGuideMsg"; +export * from "./resLocalGuideRestaurantsItem"; +export * from "./resLocalGuideSyncedAt"; +export * from "./resLogin"; +export * from "./resLoginMsg"; +export * from "./resMe"; +export * from "./resMeContactNumber"; +export * from "./resMeEmail"; +export * from "./resMeMsg"; +export * from "./resMeName"; +export * from "./resMediaList"; +export * from "./resMediaListMsg"; +export * from "./resMySites"; +export * from "./resMySitesMsg"; +export * from "./resPlace"; +export * from "./resPlaceContentList"; +export * from "./resPlaceContentListMsg"; +export * from "./resPlaceList"; +export * from "./resPlaceListMsg"; +export * from "./resPlaceMsg"; +export * from "./resPlacePlace"; +export * from "./resPlaceSearch"; +export * from "./resPlaceSearchMsg"; +export * from "./resPlaceSearchSource"; +export * from "./resPublishLogs"; +export * from "./resPublishLogsMsg"; +export * from "./resRefreshToken"; +export * from "./resRefreshTokenMsg"; +export * from "./resSeoAudit"; +export * from "./resSeoAuditMsg"; +export * from "./resSeoAuditSummary"; +export * from "./resSeoAuditVisibility"; +export * from "./resShowcase"; +export * from "./resShowcaseMsg"; +export * from "./resSite"; +export * from "./resSiteCurrentVersion"; +export * from "./resSiteMsg"; +export * from "./resSiteSite"; +export * from "./resSiteSlug"; +export * from "./resSiteSlugMsg"; +export * from "./resSiteSlugReason"; +export * from "./resSiteSlugSite"; +export * from "./resSiteSlugSuggestion"; +export * from "./resSiteVersions"; +export * from "./resSiteVersionsMsg"; +export * from "./resSlugCheck"; +export * from "./resSlugCheckMsg"; +export * from "./resSlugCheckReason"; +export * from "./resSlugCheckSuggestion"; +export * from "./resStartBuild"; +export * from "./resStartBuildJobId"; +export * from "./resStartBuildMsg"; +export * from "./resStartBuildStatus"; +export * from "./resStartCollect"; +export * from "./resStartCollectJobId"; +export * from "./resStartCollectMsg"; +export * from "./resStartCollectStatus"; +export * from "./resStartCopy"; +export * from "./resStartCopyJobId"; +export * from "./resStartCopyMsg"; +export * from "./resStartCopyStatus"; +export * from "./resStartVision"; +export * from "./resStartVisionJobId"; +export * from "./resStartVisionMsg"; +export * from "./resStartVisionStatus"; +export * from "./resSyncPlace"; +export * from "./resSyncPlaceMsg"; +export * from "./resUnit"; +export * from "./resUnitList"; +export * from "./resUnitListMsg"; +export * from "./resUnitMsg"; +export * from "./resUnitUnit"; +export * from "./resVerifyCandidates"; +export * from "./resVerifyCandidatesMsg"; +export * from "./resVerifyCandidatesReason"; +export * from "./resVerifyCandidatesSource"; +export * from "./resWeather"; +export * from "./resWeatherMsg"; +export * from "./resWeatherWeather"; +export * from "./searchPlacesPublicParams"; +export * from "./showcaseItem"; +export * from "./showcaseItemRegion"; +export * from "./showcaseItemThumbnailUrl"; +export * from "./siteData"; +export * from "./siteDataCurrentVersionId"; +export * from "./siteDataDomain"; +export * from "./siteDataPublishedAt"; +export * from "./siteDataTemplateId"; +export * from "./siteDataTheme"; +export * from "./siteDataThemeAnyOf"; +export * from "./siteDataThumbnailUrl"; +export * from "./siteStatus"; +export * from "./siteVersionData"; +export * from "./siteVersionDataBuildError"; +export * from "./siteVersionDataBuiltAt"; +export * from "./siteVersionDataCreatedAt"; +export * from "./sourceType"; +export * from "./unitData"; +export * from "./userRole"; +export * from "./validationError"; +export * from "./validationErrorCtx"; +export * from "./validationErrorLocItem"; +export * from "./verifyCandidatesParams"; +export * from "./weatherData"; +export * from "./weatherDataTimezone"; +export * from "./weatherDataWindSpeed"; diff --git a/solution/frontend/src/api/generated/model/jobData.ts b/solution/frontend/src/api/generated/model/jobData.ts index f7914a7..058e42d 100644 --- a/solution/frontend/src/api/generated/model/jobData.ts +++ b/solution/frontend/src/api/generated/model/jobData.ts @@ -4,22 +4,26 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobType } from './jobType'; -import type { JobStatus } from './jobStatus'; -import type { JobDataResult } from './jobDataResult'; -import type { JobDataLastError } from './jobDataLastError'; -import type { JobDataRunAfter } from './jobDataRunAfter'; -import type { JobDataRunStartedAt } from './jobDataRunStartedAt'; -import type { JobDataCreatedAt } from './jobDataCreatedAt'; -import type { JobDataUpdatedAt } from './jobDataUpdatedAt'; +import type { JobDataPlaceId } from "./jobDataPlaceId"; +import type { JobType } from "./jobType"; +import type { JobStatus } from "./jobStatus"; +import type { JobDataResult } from "./jobDataResult"; +import type { JobDataProgress } from "./jobDataProgress"; +import type { JobDataLastError } from "./jobDataLastError"; +import type { JobDataRunAfter } from "./jobDataRunAfter"; +import type { JobDataRunStartedAt } from "./jobDataRunStartedAt"; +import type { JobDataCreatedAt } from "./jobDataCreatedAt"; +import type { JobDataUpdatedAt } from "./jobDataUpdatedAt"; export interface JobData { job_id: string; + place_id?: JobDataPlaceId; job_type: JobType; status: JobStatus; attempts?: number; max_attempts?: number; result?: JobDataResult; + progress?: JobDataProgress; last_error?: JobDataLastError; run_after?: JobDataRunAfter; run_started_at?: JobDataRunStartedAt; diff --git a/solution/frontend/src/api/generated/model/reqCreatePlaceOwnerUserId.ts b/solution/frontend/src/api/generated/model/jobDataPlaceId.ts similarity index 67% rename from solution/frontend/src/api/generated/model/reqCreatePlaceOwnerUserId.ts rename to solution/frontend/src/api/generated/model/jobDataPlaceId.ts index 26da46e..1a89109 100644 --- a/solution/frontend/src/api/generated/model/reqCreatePlaceOwnerUserId.ts +++ b/solution/frontend/src/api/generated/model/jobDataPlaceId.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqCreatePlaceOwnerUserId = string | null; +export type JobDataPlaceId = string | null; diff --git a/solution/frontend/src/api/generated/model/jobDataProgress.ts b/solution/frontend/src/api/generated/model/jobDataProgress.ts new file mode 100644 index 0000000..3920331 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobDataProgress.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { JobProgress } from "./jobProgress"; + +export type JobDataProgress = JobProgress | null; diff --git a/solution/frontend/src/api/generated/model/jobProgress.ts b/solution/frontend/src/api/generated/model/jobProgress.ts new file mode 100644 index 0000000..3dee39b --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobProgress.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { JobStep } from "./jobStep"; + +export interface JobProgress { + steps: JobStep[]; + attempt: number; +} diff --git a/solution/frontend/src/api/generated/model/jobStatus.ts b/solution/frontend/src/api/generated/model/jobStatus.ts index 98296b3..0a1b1a3 100644 --- a/solution/frontend/src/api/generated/model/jobStatus.ts +++ b/solution/frontend/src/api/generated/model/jobStatus.ts @@ -11,8 +11,7 @@ 전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면 PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter). */ -export type JobStatus = typeof JobStatus[keyof typeof JobStatus]; - +export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const JobStatus = { diff --git a/solution/frontend/src/api/generated/model/jobStep.ts b/solution/frontend/src/api/generated/model/jobStep.ts new file mode 100644 index 0000000..79d7c88 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobStep.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { JobStepStatus } from "./jobStepStatus"; +import type { JobStepReason } from "./jobStepReason"; + +export interface JobStep { + id: string; + status: JobStepStatus; + reason?: JobStepReason; +} diff --git a/solution/frontend/src/api/generated/model/reqUpdatePlaceOwnerUserId.ts b/solution/frontend/src/api/generated/model/jobStepReason.ts similarity index 67% rename from solution/frontend/src/api/generated/model/reqUpdatePlaceOwnerUserId.ts rename to solution/frontend/src/api/generated/model/jobStepReason.ts index c1b2515..dfbe959 100644 --- a/solution/frontend/src/api/generated/model/reqUpdatePlaceOwnerUserId.ts +++ b/solution/frontend/src/api/generated/model/jobStepReason.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdatePlaceOwnerUserId = string | null; +export type JobStepReason = string | null; diff --git a/solution/frontend/src/api/generated/model/jobStepStatus.ts b/solution/frontend/src/api/generated/model/jobStepStatus.ts new file mode 100644 index 0000000..3260e13 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobStepStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type JobStepStatus = (typeof JobStepStatus)[keyof typeof JobStepStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const JobStepStatus = { + pending: "pending", + running: "running", + done: "done", + skipped: "skipped", + failed: "failed", +} as const; diff --git a/solution/frontend/src/api/generated/model/jobType.ts b/solution/frontend/src/api/generated/model/jobType.ts index be8484e..76e87bd 100644 --- a/solution/frontend/src/api/generated/model/jobType.ts +++ b/solution/frontend/src/api/generated/model/jobType.ts @@ -11,8 +11,7 @@ 무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 — 크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다. */ -export type JobType = typeof JobType[keyof typeof JobType]; - +export type JobType = (typeof JobType)[keyof typeof JobType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const JobType = { @@ -22,4 +21,6 @@ export const JobType = { BUILD: 4, LOCAL_SYNC: 5, AI_CHECK: 6, + SONG: 7, + ROLLBACK: 8, } as const; diff --git a/solution/frontend/src/api/generated/model/linkChannel.ts b/solution/frontend/src/api/generated/model/linkChannel.ts index 6ae5e7d..9587b58 100644 --- a/solution/frontend/src/api/generated/model/linkChannel.ts +++ b/solution/frontend/src/api/generated/model/linkChannel.ts @@ -6,10 +6,9 @@ */ /** - * place_links.channel 코드값. Perplexity 가 발견하는 채널 종류. + * place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류. */ -export type LinkChannel = typeof LinkChannel[keyof typeof LinkChannel]; - +export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const LinkChannel = { diff --git a/solution/frontend/src/api/generated/model/linkData.ts b/solution/frontend/src/api/generated/model/linkData.ts index 4054e12..de15e47 100644 --- a/solution/frontend/src/api/generated/model/linkData.ts +++ b/solution/frontend/src/api/generated/model/linkData.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { LinkChannel } from './linkChannel'; -import type { LinkDataTitle } from './linkDataTitle'; -import type { SourceType } from './sourceType'; -import type { LinkDataDiscoveredAt } from './linkDataDiscoveredAt'; -import type { LinkDataConfirmedAt } from './linkDataConfirmedAt'; +import type { LinkChannel } from "./linkChannel"; +import type { LinkDataTitle } from "./linkDataTitle"; +import type { SourceType } from "./sourceType"; +import type { LinkDataDiscoveredAt } from "./linkDataDiscoveredAt"; +import type { LinkDataConfirmedAt } from "./linkDataConfirmedAt"; export interface LinkData { link_id: string; diff --git a/solution/frontend/src/api/generated/model/listContentsParams.ts b/solution/frontend/src/api/generated/model/listContentsParams.ts index 0ee2437..7da0f9b 100644 --- a/solution/frontend/src/api/generated/model/listContentsParams.ts +++ b/solution/frontend/src/api/generated/model/listContentsParams.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { LocalContentStatus } from './localContentStatus'; +import type { LocalContentStatus } from "./localContentStatus"; export type ListContentsParams = { -status?: LocalContentStatus | null; -region_code?: string | null; + status?: LocalContentStatus | null; + region_code?: string | null; }; diff --git a/solution/frontend/src/api/generated/model/listFactsParams.ts b/solution/frontend/src/api/generated/model/listFactsParams.ts index c3fa47b..c522001 100644 --- a/solution/frontend/src/api/generated/model/listFactsParams.ts +++ b/solution/frontend/src/api/generated/model/listFactsParams.ts @@ -6,9 +6,9 @@ */ export type ListFactsParams = { -/** - * 객실·메뉴·프로그램 단위 fact 만 - */ -unit_id?: string | null; -publishable_only?: boolean; + /** + * 객실·메뉴·프로그램 단위 fact 만 + */ + unit_id?: string | null; + publishable_only?: boolean; }; diff --git a/solution/frontend/src/api/generated/model/listFaqsParams.ts b/solution/frontend/src/api/generated/model/listFaqsParams.ts index d306229..96d22d1 100644 --- a/solution/frontend/src/api/generated/model/listFaqsParams.ts +++ b/solution/frontend/src/api/generated/model/listFaqsParams.ts @@ -6,5 +6,5 @@ */ export type ListFaqsParams = { -publishable_only?: boolean; + publishable_only?: boolean; }; diff --git a/solution/frontend/src/api/generated/model/listLinksParams.ts b/solution/frontend/src/api/generated/model/listLinksParams.ts index 5a33484..43e45f7 100644 --- a/solution/frontend/src/api/generated/model/listLinksParams.ts +++ b/solution/frontend/src/api/generated/model/listLinksParams.ts @@ -6,5 +6,5 @@ */ export type ListLinksParams = { -confirmed_only?: boolean; + confirmed_only?: boolean; }; diff --git a/solution/frontend/src/api/generated/model/listMediaParams.ts b/solution/frontend/src/api/generated/model/listMediaParams.ts index a7cf96f..ce86aea 100644 --- a/solution/frontend/src/api/generated/model/listMediaParams.ts +++ b/solution/frontend/src/api/generated/model/listMediaParams.ts @@ -6,9 +6,9 @@ */ export type ListMediaParams = { -/** - * 객실·메뉴 단위 사진만 - */ -unit_id?: string | null; -publishable_only?: boolean; + /** + * 객실·메뉴 단위 사진만 + */ + unit_id?: string | null; + publishable_only?: boolean; }; diff --git a/solution/frontend/src/api/generated/model/listMySitesParams.ts b/solution/frontend/src/api/generated/model/listMySitesParams.ts index 9e04322..de59272 100644 --- a/solution/frontend/src/api/generated/model/listMySitesParams.ts +++ b/solution/frontend/src/api/generated/model/listMySitesParams.ts @@ -6,13 +6,13 @@ */ export type ListMySitesParams = { -/** - * @minimum 1 - */ -page?: number; -/** - * @minimum 1 - * @maximum 100 - */ -size?: number; + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; }; diff --git a/solution/frontend/src/api/generated/model/listPlacesParams.ts b/solution/frontend/src/api/generated/model/listPlacesParams.ts index 37d7f34..5d26b46 100644 --- a/solution/frontend/src/api/generated/model/listPlacesParams.ts +++ b/solution/frontend/src/api/generated/model/listPlacesParams.ts @@ -4,29 +4,29 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; -import type { PlaceStatus } from './placeStatus'; +import type { PlaceCategory } from "./placeCategory"; +import type { PlaceStatus } from "./placeStatus"; export type ListPlacesParams = { -/** - * 상호명·주소 부분일치 - */ -search?: string | null; -/** - * 업종 - */ -category?: PlaceCategory | null; -/** - * 상태 - */ -status?: PlaceStatus | null; -/** - * @minimum 1 - */ -page?: number; -/** - * @minimum 1 - * @maximum 100 - */ -size?: number; + /** + * 상호명·주소 부분일치 + */ + search?: string | null; + /** + * 업종 + */ + category?: PlaceCategory | null; + /** + * 상태 + */ + status?: PlaceStatus | null; + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; }; diff --git a/solution/frontend/src/api/generated/model/listShowcaseParams.ts b/solution/frontend/src/api/generated/model/listShowcaseParams.ts index 303f4de..f743693 100644 --- a/solution/frontend/src/api/generated/model/listShowcaseParams.ts +++ b/solution/frontend/src/api/generated/model/listShowcaseParams.ts @@ -6,10 +6,10 @@ */ export type ListShowcaseParams = { -/** - * 가져올 개수 - * @minimum 1 - * @maximum 48 - */ -limit?: number; + /** + * 가져올 개수 + * @minimum 1 + * @maximum 48 + */ + limit?: number; }; diff --git a/solution/frontend/src/api/generated/model/localContentData.ts b/solution/frontend/src/api/generated/model/localContentData.ts index 51d2f37..3e67387 100644 --- a/solution/frontend/src/api/generated/model/localContentData.ts +++ b/solution/frontend/src/api/generated/model/localContentData.ts @@ -4,16 +4,16 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { LocalContentType } from './localContentType'; -import type { LocalSource } from './localSource'; -import type { LocalContentDataExternalId } from './localContentDataExternalId'; -import type { LocalContentDataTitle } from './localContentDataTitle'; -import type { LocalContentDataBody } from './localContentDataBody'; -import type { LocalContentStatus } from './localContentStatus'; -import type { LocalContentDataCollectedAt } from './localContentDataCollectedAt'; -import type { LocalContentDataPublishedAt } from './localContentDataPublishedAt'; -import type { LocalContentDataDisplayStartAt } from './localContentDataDisplayStartAt'; -import type { LocalContentDataDisplayEndAt } from './localContentDataDisplayEndAt'; +import type { LocalContentType } from "./localContentType"; +import type { LocalSource } from "./localSource"; +import type { LocalContentDataExternalId } from "./localContentDataExternalId"; +import type { LocalContentDataTitle } from "./localContentDataTitle"; +import type { LocalContentDataBody } from "./localContentDataBody"; +import type { LocalContentStatus } from "./localContentStatus"; +import type { LocalContentDataCollectedAt } from "./localContentDataCollectedAt"; +import type { LocalContentDataPublishedAt } from "./localContentDataPublishedAt"; +import type { LocalContentDataDisplayStartAt } from "./localContentDataDisplayStartAt"; +import type { LocalContentDataDisplayEndAt } from "./localContentDataDisplayEndAt"; export interface LocalContentData { local_content_id: string; diff --git a/solution/frontend/src/api/generated/model/localContentStatus.ts b/solution/frontend/src/api/generated/model/localContentStatus.ts index 51092d9..a8747c9 100644 --- a/solution/frontend/src/api/generated/model/localContentStatus.ts +++ b/solution/frontend/src/api/generated/model/localContentStatus.ts @@ -8,8 +8,8 @@ /** * 운영 관리자가 검수하는 지역 콘텐츠 발행 상태. */ -export type LocalContentStatus = typeof LocalContentStatus[keyof typeof LocalContentStatus]; - +export type LocalContentStatus = + (typeof LocalContentStatus)[keyof typeof LocalContentStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const LocalContentStatus = { diff --git a/solution/frontend/src/api/generated/model/localContentType.ts b/solution/frontend/src/api/generated/model/localContentType.ts index 36d49de..753d981 100644 --- a/solution/frontend/src/api/generated/model/localContentType.ts +++ b/solution/frontend/src/api/generated/model/localContentType.ts @@ -8,8 +8,8 @@ /** * local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류. */ -export type LocalContentType = typeof LocalContentType[keyof typeof LocalContentType]; - +export type LocalContentType = + (typeof LocalContentType)[keyof typeof LocalContentType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const LocalContentType = { @@ -17,4 +17,6 @@ export const LocalContentType = { FESTIVAL: 2, ATTRACTION: 3, RESTAURANT: 4, + COURSE: 5, + STORY: 6, } as const; diff --git a/solution/frontend/src/api/generated/model/localSource.ts b/solution/frontend/src/api/generated/model/localSource.ts index 163adc6..99c6133 100644 --- a/solution/frontend/src/api/generated/model/localSource.ts +++ b/solution/frontend/src/api/generated/model/localSource.ts @@ -8,8 +8,7 @@ /** * local_contents.source 코드값. 어느 외부 API 에서 왔는지. */ -export type LocalSource = typeof LocalSource[keyof typeof LocalSource]; - +export type LocalSource = (typeof LocalSource)[keyof typeof LocalSource]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const LocalSource = { @@ -17,4 +16,6 @@ export const LocalSource = { TOUR_API: 2, KAKAO_LOCAL: 3, OFFICIAL_WEB: 4, + LLM: 5, + NAVER_CRAWL: 6, } as const; diff --git a/solution/frontend/src/api/generated/model/mediaData.ts b/solution/frontend/src/api/generated/model/mediaData.ts index 1539854..3d56d07 100644 --- a/solution/frontend/src/api/generated/model/mediaData.ts +++ b/solution/frontend/src/api/generated/model/mediaData.ts @@ -4,17 +4,17 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { MediaDataUnitId } from './mediaDataUnitId'; -import type { MediaDataOriginUrl } from './mediaDataOriginUrl'; -import type { SourceType } from './sourceType'; -import type { MediaDataSourceUrl } from './mediaDataSourceUrl'; -import type { MediaDataLabel } from './mediaDataLabel'; -import type { MediaDataAltText } from './mediaDataAltText'; -import type { MediaDataVisionConfidence } from './mediaDataVisionConfidence'; -import type { MediaStatus } from './mediaStatus'; -import type { MediaDataWidth } from './mediaDataWidth'; -import type { MediaDataHeight } from './mediaDataHeight'; -import type { MediaDataCreatedAt } from './mediaDataCreatedAt'; +import type { MediaDataUnitId } from "./mediaDataUnitId"; +import type { MediaDataOriginUrl } from "./mediaDataOriginUrl"; +import type { SourceType } from "./sourceType"; +import type { MediaDataSourceUrl } from "./mediaDataSourceUrl"; +import type { MediaDataLabel } from "./mediaDataLabel"; +import type { MediaDataAltText } from "./mediaDataAltText"; +import type { MediaDataVisionConfidence } from "./mediaDataVisionConfidence"; +import type { MediaStatus } from "./mediaStatus"; +import type { MediaDataWidth } from "./mediaDataWidth"; +import type { MediaDataHeight } from "./mediaDataHeight"; +import type { MediaDataCreatedAt } from "./mediaDataCreatedAt"; /** * 사진 1건. diff --git a/solution/frontend/src/api/generated/model/mediaStatus.ts b/solution/frontend/src/api/generated/model/mediaStatus.ts index f8541c1..0b997c5 100644 --- a/solution/frontend/src/api/generated/model/mediaStatus.ts +++ b/solution/frontend/src/api/generated/model/mediaStatus.ts @@ -8,8 +8,7 @@ /** * media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다. */ -export type MediaStatus = typeof MediaStatus[keyof typeof MediaStatus]; - +export type MediaStatus = (typeof MediaStatus)[keyof typeof MediaStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const MediaStatus = { diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts index b3ef1d2..407fefe 100644 --- a/solution/frontend/src/api/generated/model/mySiteData.ts +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -4,16 +4,16 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; -import type { PlaceStatus } from './placeStatus'; -import type { MySiteDataRoadAddress } from './mySiteDataRoadAddress'; -import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt'; -import type { MySiteDataSiteId } from './mySiteDataSiteId'; -import type { MySiteDataStatus } from './mySiteDataStatus'; -import type { MySiteDataDomain } from './mySiteDataDomain'; -import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; -import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; -import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl'; +import type { PlaceCategory } from "./placeCategory"; +import type { PlaceStatus } from "./placeStatus"; +import type { MySiteDataRoadAddress } from "./mySiteDataRoadAddress"; +import type { MySiteDataCreatedAt } from "./mySiteDataCreatedAt"; +import type { MySiteDataSiteId } from "./mySiteDataSiteId"; +import type { MySiteDataStatus } from "./mySiteDataStatus"; +import type { MySiteDataDomain } from "./mySiteDataDomain"; +import type { MySiteDataTemplateId } from "./mySiteDataTemplateId"; +import type { MySiteDataPublishedAt } from "./mySiteDataPublishedAt"; +import type { MySiteDataThumbnailUrl } from "./mySiteDataThumbnailUrl"; /** * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). @@ -33,7 +33,6 @@ export interface MySiteData { domain?: MySiteDataDomain; template_id?: MySiteDataTemplateId; published_at?: MySiteDataPublishedAt; - /** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */ thumbnail_url?: MySiteDataThumbnailUrl; needs_rebuild?: boolean; } diff --git a/solution/frontend/src/api/generated/model/mySiteDataStatus.ts b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts index 66b4a78..be84a5a 100644 --- a/solution/frontend/src/api/generated/model/mySiteDataStatus.ts +++ b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteStatus } from './siteStatus'; +import type { SiteStatus } from "./siteStatus"; export type MySiteDataStatus = SiteStatus | null; diff --git a/solution/frontend/src/api/generated/model/placeCandidate.ts b/solution/frontend/src/api/generated/model/placeCandidate.ts index 0cd639a..d48b187 100644 --- a/solution/frontend/src/api/generated/model/placeCandidate.ts +++ b/solution/frontend/src/api/generated/model/placeCandidate.ts @@ -4,15 +4,15 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCandidateExternalPlaceId } from './placeCandidateExternalPlaceId'; -import type { PlaceCandidateRoadAddress } from './placeCandidateRoadAddress'; -import type { PlaceCandidateAddress } from './placeCandidateAddress'; -import type { PlaceCandidatePhone } from './placeCandidatePhone'; -import type { PlaceCandidateLatitude } from './placeCandidateLatitude'; -import type { PlaceCandidateLongitude } from './placeCandidateLongitude'; -import type { PlaceCandidateCategoryName } from './placeCandidateCategoryName'; -import type { PlaceCandidatePlaceUrl } from './placeCandidatePlaceUrl'; -import type { PlaceCandidateNaverPlaceUrl } from './placeCandidateNaverPlaceUrl'; +import type { PlaceCandidateExternalPlaceId } from "./placeCandidateExternalPlaceId"; +import type { PlaceCandidateRoadAddress } from "./placeCandidateRoadAddress"; +import type { PlaceCandidateAddress } from "./placeCandidateAddress"; +import type { PlaceCandidatePhone } from "./placeCandidatePhone"; +import type { PlaceCandidateLatitude } from "./placeCandidateLatitude"; +import type { PlaceCandidateLongitude } from "./placeCandidateLongitude"; +import type { PlaceCandidateCategoryName } from "./placeCandidateCategoryName"; +import type { PlaceCandidatePlaceUrl } from "./placeCandidatePlaceUrl"; +import type { PlaceCandidateNaverPlaceUrl } from "./placeCandidateNaverPlaceUrl"; /** * 외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다. diff --git a/solution/frontend/src/api/generated/model/placeCategory.ts b/solution/frontend/src/api/generated/model/placeCategory.ts index f890cb2..27a66b8 100644 --- a/solution/frontend/src/api/generated/model/placeCategory.ts +++ b/solution/frontend/src/api/generated/model/placeCategory.ts @@ -9,8 +9,7 @@ * places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1. 업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가. */ -export type PlaceCategory = typeof PlaceCategory[keyof typeof PlaceCategory]; - +export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PlaceCategory = { diff --git a/solution/frontend/src/api/generated/model/placeContentData.ts b/solution/frontend/src/api/generated/model/placeContentData.ts new file mode 100644 index 0000000..33969c8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeContentData.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { LocalContentType } from "./localContentType"; +import type { PlaceContentDataBody } from "./placeContentDataBody"; +import type { PlaceContentDataDisplayEndAt } from "./placeContentDataDisplayEndAt"; +import type { PlaceContentDataCollectedAt } from "./placeContentDataCollectedAt"; + +/** + * 업장 반경 주변정보 1건(admin 목록용). + */ +export interface PlaceContentData { + place_content_id: string; + place_id: string; + content_type: LocalContentType; + external_id: string; + title: string; + body: PlaceContentDataBody; + distance_m: number; + has_image: boolean; + hidden: boolean; + display_end_at?: PlaceContentDataDisplayEndAt; + collected_at?: PlaceContentDataCollectedAt; +} diff --git a/solution/frontend/src/api/generated/model/placeContentDataBody.ts b/solution/frontend/src/api/generated/model/placeContentDataBody.ts new file mode 100644 index 0000000..1186260 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeContentDataBody.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type PlaceContentDataBody = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/placeContentDataCollectedAt.ts b/solution/frontend/src/api/generated/model/placeContentDataCollectedAt.ts new file mode 100644 index 0000000..c76d886 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeContentDataCollectedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type PlaceContentDataCollectedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/placeContentDataDisplayEndAt.ts b/solution/frontend/src/api/generated/model/placeContentDataDisplayEndAt.ts new file mode 100644 index 0000000..d46531c --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeContentDataDisplayEndAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type PlaceContentDataDisplayEndAt = string | null; diff --git a/solution/frontend/src/api/generated/model/placeData.ts b/solution/frontend/src/api/generated/model/placeData.ts index 7fbfef8..ca5cad6 100644 --- a/solution/frontend/src/api/generated/model/placeData.ts +++ b/solution/frontend/src/api/generated/model/placeData.ts @@ -4,20 +4,20 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; -import type { PlaceStatus } from './placeStatus'; -import type { PlaceDataOwnerUserId } from './placeDataOwnerUserId'; -import type { PlaceDataExternalSource } from './placeDataExternalSource'; -import type { PlaceDataExternalPlaceId } from './placeDataExternalPlaceId'; -import type { PlaceDataRoadAddress } from './placeDataRoadAddress'; -import type { PlaceDataAddress } from './placeDataAddress'; -import type { PlaceDataPhone } from './placeDataPhone'; -import type { PlaceDataLatitude } from './placeDataLatitude'; -import type { PlaceDataLongitude } from './placeDataLongitude'; -import type { PlaceDataRegionCode } from './placeDataRegionCode'; -import type { PlaceDataVerifiedAt } from './placeDataVerifiedAt'; -import type { PlaceDataContentUpdatedAt } from './placeDataContentUpdatedAt'; -import type { PlaceDataCreatedAt } from './placeDataCreatedAt'; +import type { PlaceCategory } from "./placeCategory"; +import type { PlaceStatus } from "./placeStatus"; +import type { PlaceDataOwnerUserId } from "./placeDataOwnerUserId"; +import type { PlaceDataExternalSource } from "./placeDataExternalSource"; +import type { PlaceDataExternalPlaceId } from "./placeDataExternalPlaceId"; +import type { PlaceDataRoadAddress } from "./placeDataRoadAddress"; +import type { PlaceDataAddress } from "./placeDataAddress"; +import type { PlaceDataPhone } from "./placeDataPhone"; +import type { PlaceDataLatitude } from "./placeDataLatitude"; +import type { PlaceDataLongitude } from "./placeDataLongitude"; +import type { PlaceDataRegionCode } from "./placeDataRegionCode"; +import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt"; +import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt"; +import type { PlaceDataCreatedAt } from "./placeDataCreatedAt"; export interface PlaceData { place_id: string; diff --git a/solution/frontend/src/api/generated/model/placeDataExternalSource.ts b/solution/frontend/src/api/generated/model/placeDataExternalSource.ts index 03c0749..7f9febf 100644 --- a/solution/frontend/src/api/generated/model/placeDataExternalSource.ts +++ b/solution/frontend/src/api/generated/model/placeDataExternalSource.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ExternalPlaceSource } from './externalPlaceSource'; +import type { ExternalPlaceSource } from "./externalPlaceSource"; export type PlaceDataExternalSource = ExternalPlaceSource | null; diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts index ee94833..5007240 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItem.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -4,9 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceSearchItemRoadAddress } from './placeSearchItemRoadAddress'; -import type { PlaceSearchItemCategoryName } from './placeSearchItemCategoryName'; -import type { PlaceSearchItemCategory } from './placeSearchItemCategory'; +import type { PlaceSearchItemRoadAddress } from "./placeSearchItemRoadAddress"; +import type { PlaceSearchItemCategoryName } from "./placeSearchItemCategoryName"; +import type { PlaceSearchItemCategory } from "./placeSearchItemCategory"; +import type { PlaceSearchItemNaverPlaceUrl } from "./placeSearchItemNaverPlaceUrl"; /** * 공개 검색 결과 1건. @@ -21,6 +22,5 @@ export interface PlaceSearchItem { road_address?: PlaceSearchItemRoadAddress; category_name?: PlaceSearchItemCategoryName; category?: PlaceSearchItemCategory; - /** @nullable */ - naver_place_url?: string | null; + naver_place_url?: PlaceSearchItemNaverPlaceUrl; } diff --git a/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts b/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts index 4c4e071..7f2be7d 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; +import type { PlaceCategory } from "./placeCategory"; export type PlaceSearchItemCategory = PlaceCategory | null; diff --git a/solution/frontend/src/api/generated/model/placeStatus.ts b/solution/frontend/src/api/generated/model/placeStatus.ts index 5693a00..960273d 100644 --- a/solution/frontend/src/api/generated/model/placeStatus.ts +++ b/solution/frontend/src/api/generated/model/placeStatus.ts @@ -9,8 +9,7 @@ * places.status 코드값. 사업장 생애주기. 해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다). */ -export type PlaceStatus = typeof PlaceStatus[keyof typeof PlaceStatus]; - +export type PlaceStatus = (typeof PlaceStatus)[keyof typeof PlaceStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PlaceStatus = { diff --git a/solution/frontend/src/api/generated/model/publishAction.ts b/solution/frontend/src/api/generated/model/publishAction.ts index 0d56e92..16d1acd 100644 --- a/solution/frontend/src/api/generated/model/publishAction.ts +++ b/solution/frontend/src/api/generated/model/publishAction.ts @@ -8,8 +8,7 @@ /** * publish_logs.action 코드값. */ -export type PublishAction = typeof PublishAction[keyof typeof PublishAction]; - +export type PublishAction = (typeof PublishAction)[keyof typeof PublishAction]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PublishAction = { @@ -18,4 +17,5 @@ export const PublishAction = { REBUILD: 3, SUSPEND: 4, RESUME: 5, + ROLLBACK: 6, } as const; diff --git a/solution/frontend/src/api/generated/model/publishLogData.ts b/solution/frontend/src/api/generated/model/publishLogData.ts index 2e0ed79..b2714b6 100644 --- a/solution/frontend/src/api/generated/model/publishLogData.ts +++ b/solution/frontend/src/api/generated/model/publishLogData.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PublishAction } from './publishAction'; -import type { PublishResult } from './publishResult'; -import type { PublishLogDataRejectReason } from './publishLogDataRejectReason'; -import type { PublishLogDataDetail } from './publishLogDataDetail'; -import type { PublishLogDataCreatedAt } from './publishLogDataCreatedAt'; +import type { PublishAction } from "./publishAction"; +import type { PublishResult } from "./publishResult"; +import type { PublishLogDataRejectReason } from "./publishLogDataRejectReason"; +import type { PublishLogDataDetail } from "./publishLogDataDetail"; +import type { PublishLogDataCreatedAt } from "./publishLogDataCreatedAt"; export interface PublishLogData { publish_log_id: string; diff --git a/solution/frontend/src/api/generated/model/publishLogDataRejectReason.ts b/solution/frontend/src/api/generated/model/publishLogDataRejectReason.ts index db9fb8f..a1294b6 100644 --- a/solution/frontend/src/api/generated/model/publishLogDataRejectReason.ts +++ b/solution/frontend/src/api/generated/model/publishLogDataRejectReason.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PublishRejectReason } from './publishRejectReason'; +import type { PublishRejectReason } from "./publishRejectReason"; export type PublishLogDataRejectReason = PublishRejectReason | null; diff --git a/solution/frontend/src/api/generated/model/publishRejectReason.ts b/solution/frontend/src/api/generated/model/publishRejectReason.ts index 6fcb170..cbd0992 100644 --- a/solution/frontend/src/api/generated/model/publishRejectReason.ts +++ b/solution/frontend/src/api/generated/model/publishRejectReason.ts @@ -8,8 +8,8 @@ /** * publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3). */ -export type PublishRejectReason = typeof PublishRejectReason[keyof typeof PublishRejectReason]; - +export type PublishRejectReason = + (typeof PublishRejectReason)[keyof typeof PublishRejectReason]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PublishRejectReason = { diff --git a/solution/frontend/src/api/generated/model/publishResult.ts b/solution/frontend/src/api/generated/model/publishResult.ts index 1c2d2a4..8f68766 100644 --- a/solution/frontend/src/api/generated/model/publishResult.ts +++ b/solution/frontend/src/api/generated/model/publishResult.ts @@ -8,8 +8,7 @@ /** * publish_logs.result 코드값. */ -export type PublishResult = typeof PublishResult[keyof typeof PublishResult]; - +export type PublishResult = (typeof PublishResult)[keyof typeof PublishResult]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PublishResult = { diff --git a/solution/frontend/src/api/generated/model/relayParams.ts b/solution/frontend/src/api/generated/model/relayParams.ts new file mode 100644 index 0000000..fd195c6 --- /dev/null +++ b/solution/frontend/src/api/generated/model/relayParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type RelayParams = { + /** + * @minLength 8 + * @maxLength 2000 + */ + url: string; +}; diff --git a/solution/frontend/src/api/generated/model/renderStatusData.ts b/solution/frontend/src/api/generated/model/renderStatusData.ts index 697aa96..4f20757 100644 --- a/solution/frontend/src/api/generated/model/renderStatusData.ts +++ b/solution/frontend/src/api/generated/model/renderStatusData.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { RenderStatusDataRenderedAt } from './renderStatusDataRenderedAt'; -import type { RenderStatusDataRenderedVersion } from './renderStatusDataRenderedVersion'; -import type { RenderStatusDataError } from './renderStatusDataError'; +import type { RenderStatusDataRenderedAt } from "./renderStatusDataRenderedAt"; +import type { RenderStatusDataRenderedVersion } from "./renderStatusDataRenderedVersion"; +import type { RenderStatusDataError } from "./renderStatusDataError"; /** * 정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다. diff --git a/solution/frontend/src/api/generated/model/reqCreateFaq.ts b/solution/frontend/src/api/generated/model/reqCreateFaq.ts index 7e5c5f6..45f96b8 100644 --- a/solution/frontend/src/api/generated/model/reqCreateFaq.ts +++ b/solution/frontend/src/api/generated/model/reqCreateFaq.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqCreateFaqSortOrder } from './reqCreateFaqSortOrder'; +import type { ReqCreateFaqSortOrder } from "./reqCreateFaqSortOrder"; /** * 사장님이 직접 쓴 FAQ. diff --git a/solution/frontend/src/api/generated/model/reqCreateLink.ts b/solution/frontend/src/api/generated/model/reqCreateLink.ts index df1e00b..6767e5c 100644 --- a/solution/frontend/src/api/generated/model/reqCreateLink.ts +++ b/solution/frontend/src/api/generated/model/reqCreateLink.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { LinkChannel } from './linkChannel'; -import type { ReqCreateLinkTitle } from './reqCreateLinkTitle'; -import type { SourceType } from './sourceType'; +import type { LinkChannel } from "./linkChannel"; +import type { ReqCreateLinkTitle } from "./reqCreateLinkTitle"; +import type { SourceType } from "./sourceType"; export interface ReqCreateLink { channel?: LinkChannel; diff --git a/solution/frontend/src/api/generated/model/reqCreatePlace.ts b/solution/frontend/src/api/generated/model/reqCreatePlace.ts index d117aab..5b53147 100644 --- a/solution/frontend/src/api/generated/model/reqCreatePlace.ts +++ b/solution/frontend/src/api/generated/model/reqCreatePlace.ts @@ -4,11 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; -import type { ReqCreatePlaceOwnerUserId } from './reqCreatePlaceOwnerUserId'; +import type { PlaceCategory } from "./placeCategory"; export interface ReqCreatePlace { name?: string; category?: PlaceCategory; - owner_user_id?: ReqCreatePlaceOwnerUserId; } diff --git a/solution/frontend/src/api/generated/model/reqHidePlaceContent.ts b/solution/frontend/src/api/generated/model/reqHidePlaceContent.ts new file mode 100644 index 0000000..d42f34c --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqHidePlaceContent.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqHidePlaceContent { + hidden: boolean; +} diff --git a/solution/frontend/src/api/generated/model/reqRollback.ts b/solution/frontend/src/api/generated/model/reqRollback.ts new file mode 100644 index 0000000..1b8f5e4 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqRollback.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +/** + * 예전 버전으로 공개 주소를 되돌린다. + +★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면 + (보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서 + 돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는 + 그대로 통과한다). + */ +export interface ReqRollback { + target_version: number; +} diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts index ca5de09..7ca2aa0 100644 --- a/solution/frontend/src/api/generated/model/reqSignup.ts +++ b/solution/frontend/src/api/generated/model/reqSignup.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqSignupName } from './reqSignupName'; +import type { ReqSignupName } from "./reqSignupName"; /** * id/pw 가입. 가입 = 계정 1개다. diff --git a/solution/frontend/src/api/generated/model/reqSiteStatus.ts b/solution/frontend/src/api/generated/model/reqSiteStatus.ts index d1037bd..aaa369f 100644 --- a/solution/frontend/src/api/generated/model/reqSiteStatus.ts +++ b/solution/frontend/src/api/generated/model/reqSiteStatus.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PublishAction } from './publishAction'; +import type { PublishAction } from "./publishAction"; /** * 발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — diff --git a/solution/frontend/src/api/generated/model/reqSiteTheme.ts b/solution/frontend/src/api/generated/model/reqSiteTheme.ts index eff465b..ebaee6a 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTheme.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTheme.ts @@ -4,7 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqSiteThemeTheme } from './reqSiteThemeTheme'; +import type { ReqSiteThemeTheme } from "./reqSiteThemeTheme"; /** * 디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다. diff --git a/solution/frontend/src/api/generated/model/reqStartCopy.ts b/solution/frontend/src/api/generated/model/reqStartCopy.ts index 65d37ca..24219d8 100644 --- a/solution/frontend/src/api/generated/model/reqStartCopy.ts +++ b/solution/frontend/src/api/generated/model/reqStartCopy.ts @@ -10,4 +10,6 @@ ★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다). */ -export interface ReqStartCopy { [key: string]: unknown } +export interface ReqStartCopy { + resume?: boolean; +} diff --git a/solution/frontend/src/api/generated/model/reqSyncFestivals.ts b/solution/frontend/src/api/generated/model/reqSyncFestivals.ts deleted file mode 100644 index 5dbf5e9..0000000 --- a/solution/frontend/src/api/generated/model/reqSyncFestivals.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ -import type { ReqSyncFestivalsAreaCode } from './reqSyncFestivalsAreaCode'; -import type { ReqSyncFestivalsStartDate } from './reqSyncFestivalsStartDate'; - -export interface ReqSyncFestivals { - /** - * @minLength 1 - * @maxLength 10 - */ - region_code: string; - area_code?: ReqSyncFestivalsAreaCode; - start_date?: ReqSyncFestivalsStartDate; -} diff --git a/solution/frontend/src/api/generated/model/reqTransitionFact.ts b/solution/frontend/src/api/generated/model/reqTransitionFact.ts index 2cdbcfe..c607356 100644 --- a/solution/frontend/src/api/generated/model/reqTransitionFact.ts +++ b/solution/frontend/src/api/generated/model/reqTransitionFact.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FactStatus } from './factStatus'; -import type { ReqTransitionFactValue } from './reqTransitionFactValue'; +import type { FactStatus } from "./factStatus"; +import type { ReqTransitionFactValue } from "./reqTransitionFactValue"; /** * 검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다. diff --git a/solution/frontend/src/api/generated/model/reqTransitionFaq.ts b/solution/frontend/src/api/generated/model/reqTransitionFaq.ts index a83b581..571b384 100644 --- a/solution/frontend/src/api/generated/model/reqTransitionFaq.ts +++ b/solution/frontend/src/api/generated/model/reqTransitionFaq.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FactStatus } from './factStatus'; -import type { ReqTransitionFaqQuestion } from './reqTransitionFaqQuestion'; -import type { ReqTransitionFaqAnswer } from './reqTransitionFaqAnswer'; +import type { FactStatus } from "./factStatus"; +import type { ReqTransitionFaqQuestion } from "./reqTransitionFaqQuestion"; +import type { ReqTransitionFaqAnswer } from "./reqTransitionFaqAnswer"; /** * 검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다. diff --git a/solution/frontend/src/api/generated/model/reqUpdateLocalContent.ts b/solution/frontend/src/api/generated/model/reqUpdateLocalContent.ts index 1d868f0..5cfcdff 100644 --- a/solution/frontend/src/api/generated/model/reqUpdateLocalContent.ts +++ b/solution/frontend/src/api/generated/model/reqUpdateLocalContent.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqUpdateLocalContentTitle } from './reqUpdateLocalContentTitle'; -import type { ReqUpdateLocalContentBody } from './reqUpdateLocalContentBody'; -import type { ReqUpdateLocalContentDisplayStartAt } from './reqUpdateLocalContentDisplayStartAt'; -import type { ReqUpdateLocalContentDisplayEndAt } from './reqUpdateLocalContentDisplayEndAt'; +import type { ReqUpdateLocalContentTitle } from "./reqUpdateLocalContentTitle"; +import type { ReqUpdateLocalContentBody } from "./reqUpdateLocalContentBody"; +import type { ReqUpdateLocalContentDisplayStartAt } from "./reqUpdateLocalContentDisplayStartAt"; +import type { ReqUpdateLocalContentDisplayEndAt } from "./reqUpdateLocalContentDisplayEndAt"; export interface ReqUpdateLocalContent { title?: ReqUpdateLocalContentTitle; diff --git a/solution/frontend/src/api/generated/model/reqUpdateLocalContentBody.ts b/solution/frontend/src/api/generated/model/reqUpdateLocalContentBody.ts index be3d4ce..54cbd1b 100644 --- a/solution/frontend/src/api/generated/model/reqUpdateLocalContentBody.ts +++ b/solution/frontend/src/api/generated/model/reqUpdateLocalContentBody.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqUpdateLocalContentBodyAnyOf } from './reqUpdateLocalContentBodyAnyOf'; +import type { ReqUpdateLocalContentBodyAnyOf } from "./reqUpdateLocalContentBodyAnyOf"; export type ReqUpdateLocalContentBody = ReqUpdateLocalContentBodyAnyOf | null; diff --git a/solution/frontend/src/api/generated/model/reqUpdateMe.ts b/solution/frontend/src/api/generated/model/reqUpdateMe.ts index 288ebaa..cb0360c 100644 --- a/solution/frontend/src/api/generated/model/reqUpdateMe.ts +++ b/solution/frontend/src/api/generated/model/reqUpdateMe.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqUpdateMeName } from './reqUpdateMeName'; -import type { ReqUpdateMeEmail } from './reqUpdateMeEmail'; -import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber'; -import type { ReqUpdateMePassword } from './reqUpdateMePassword'; +import type { ReqUpdateMeName } from "./reqUpdateMeName"; +import type { ReqUpdateMeEmail } from "./reqUpdateMeEmail"; +import type { ReqUpdateMeContactNumber } from "./reqUpdateMeContactNumber"; +import type { ReqUpdateMePassword } from "./reqUpdateMePassword"; export interface ReqUpdateMe { name?: ReqUpdateMeName; diff --git a/solution/frontend/src/api/generated/model/reqUpdatePlace.ts b/solution/frontend/src/api/generated/model/reqUpdatePlace.ts index 86f7688..5b7a7a0 100644 --- a/solution/frontend/src/api/generated/model/reqUpdatePlace.ts +++ b/solution/frontend/src/api/generated/model/reqUpdatePlace.ts @@ -4,12 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqUpdatePlaceName } from './reqUpdatePlaceName'; -import type { ReqUpdatePlaceOwnerUserId } from './reqUpdatePlaceOwnerUserId'; -import type { ReqUpdatePlaceStatus } from './reqUpdatePlaceStatus'; +import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName"; +import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus"; export interface ReqUpdatePlace { name?: ReqUpdatePlaceName; - owner_user_id?: ReqUpdatePlaceOwnerUserId; status?: ReqUpdatePlaceStatus; } diff --git a/solution/frontend/src/api/generated/model/reqUpdatePlaceStatus.ts b/solution/frontend/src/api/generated/model/reqUpdatePlaceStatus.ts index a1da292..14bb823 100644 --- a/solution/frontend/src/api/generated/model/reqUpdatePlaceStatus.ts +++ b/solution/frontend/src/api/generated/model/reqUpdatePlaceStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceStatus } from './placeStatus'; +import type { PlaceStatus } from "./placeStatus"; export type ReqUpdatePlaceStatus = PlaceStatus | null; diff --git a/solution/frontend/src/api/generated/model/reqUpsertFact.ts b/solution/frontend/src/api/generated/model/reqUpsertFact.ts index 24c2e6e..057576f 100644 --- a/solution/frontend/src/api/generated/model/reqUpsertFact.ts +++ b/solution/frontend/src/api/generated/model/reqUpsertFact.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ReqUpsertFactValue } from './reqUpsertFactValue'; -import type { ReqUpsertFactUnitId } from './reqUpsertFactUnitId'; -import type { SourceType } from './sourceType'; -import type { ReqUpsertFactSourceUrl } from './reqUpsertFactSourceUrl'; -import type { ReqUpsertFactExpiresAt } from './reqUpsertFactExpiresAt'; +import type { ReqUpsertFactValue } from "./reqUpsertFactValue"; +import type { ReqUpsertFactUnitId } from "./reqUpsertFactUnitId"; +import type { SourceType } from "./sourceType"; +import type { ReqUpsertFactSourceUrl } from "./reqUpsertFactSourceUrl"; +import type { ReqUpsertFactExpiresAt } from "./reqUpsertFactExpiresAt"; /** * fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다. diff --git a/solution/frontend/src/api/generated/model/reqVerifyPlace.ts b/solution/frontend/src/api/generated/model/reqVerifyPlace.ts index 30de6c0..bee8de0 100644 --- a/solution/frontend/src/api/generated/model/reqVerifyPlace.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlace.ts @@ -4,14 +4,15 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ExternalPlaceSource } from './externalPlaceSource'; -import type { ReqVerifyPlacePlaceUrl } from './reqVerifyPlacePlaceUrl'; -import type { ReqVerifyPlaceRoadAddress } from './reqVerifyPlaceRoadAddress'; -import type { ReqVerifyPlaceAddress } from './reqVerifyPlaceAddress'; -import type { ReqVerifyPlacePhone } from './reqVerifyPlacePhone'; -import type { ReqVerifyPlaceLatitude } from './reqVerifyPlaceLatitude'; -import type { ReqVerifyPlaceLongitude } from './reqVerifyPlaceLongitude'; -import type { ReqVerifyPlaceRegionCode } from './reqVerifyPlaceRegionCode'; +import type { ExternalPlaceSource } from "./externalPlaceSource"; +import type { ReqVerifyPlacePlaceUrl } from "./reqVerifyPlacePlaceUrl"; +import type { ReqVerifyPlaceRoadAddress } from "./reqVerifyPlaceRoadAddress"; +import type { ReqVerifyPlaceAddress } from "./reqVerifyPlaceAddress"; +import type { ReqVerifyPlacePhone } from "./reqVerifyPlacePhone"; +import type { ReqVerifyPlaceLatitude } from "./reqVerifyPlaceLatitude"; +import type { ReqVerifyPlaceLongitude } from "./reqVerifyPlaceLongitude"; +import type { ReqVerifyPlaceRegionCode } from "./reqVerifyPlaceRegionCode"; +import type { ReqVerifyPlaceCategoryName } from "./reqVerifyPlaceCategoryName"; /** * 카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정). @@ -30,4 +31,5 @@ export interface ReqVerifyPlace { latitude?: ReqVerifyPlaceLatitude; longitude?: ReqVerifyPlaceLongitude; region_code?: ReqVerifyPlaceRegionCode; + category_name?: ReqVerifyPlaceCategoryName; } diff --git a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts index 2c9d898..4ec5626 100644 --- a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts @@ -19,4 +19,6 @@ */ export interface ReqVerifyPlaceByUrl { url?: string; + /** 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. 기본 true. */ + reuse_existing?: boolean; } diff --git a/solution/frontend/src/api/generated/model/reqSyncFestivalsStartDate.ts b/solution/frontend/src/api/generated/model/reqVerifyPlaceCategoryName.ts similarity index 67% rename from solution/frontend/src/api/generated/model/reqSyncFestivalsStartDate.ts rename to solution/frontend/src/api/generated/model/reqVerifyPlaceCategoryName.ts index 5d2794a..862cdea 100644 --- a/solution/frontend/src/api/generated/model/reqSyncFestivalsStartDate.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlaceCategoryName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqSyncFestivalsStartDate = string | null; +export type ReqVerifyPlaceCategoryName = string | null; diff --git a/solution/frontend/src/api/generated/model/resCategorySchema.ts b/solution/frontend/src/api/generated/model/resCategorySchema.ts index bf4a647..e6cfada 100644 --- a/solution/frontend/src/api/generated/model/resCategorySchema.ts +++ b/solution/frontend/src/api/generated/model/resCategorySchema.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResCategorySchemaMsg } from './resCategorySchemaMsg'; -import type { ResCategorySchemaCategory } from './resCategorySchemaCategory'; -import type { ResCategorySchemaLabel } from './resCategorySchemaLabel'; -import type { FieldSpecData } from './fieldSpecData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResCategorySchemaMsg } from "./resCategorySchemaMsg"; +import type { ResCategorySchemaCategory } from "./resCategorySchemaCategory"; +import type { ResCategorySchemaLabel } from "./resCategorySchemaLabel"; +import type { FieldSpecData } from "./fieldSpecData"; export interface ResCategorySchema { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resExtractFacts.ts b/solution/frontend/src/api/generated/model/resExtractFacts.ts index 0c0c9ed..9cdfa1c 100644 --- a/solution/frontend/src/api/generated/model/resExtractFacts.ts +++ b/solution/frontend/src/api/generated/model/resExtractFacts.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResExtractFactsMsg } from './resExtractFactsMsg'; -import type { ResExtractedFact } from './resExtractedFact'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResExtractFactsMsg } from "./resExtractFactsMsg"; +import type { ResExtractedFact } from "./resExtractedFact"; /** * 뽑힌 것과 버려진 것을 **둘 다** 돌려준다. diff --git a/solution/frontend/src/api/generated/model/resExtractedFact.ts b/solution/frontend/src/api/generated/model/resExtractedFact.ts index b468695..64ebddf 100644 --- a/solution/frontend/src/api/generated/model/resExtractedFact.ts +++ b/solution/frontend/src/api/generated/model/resExtractedFact.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ResExtractedFactUnitName } from './resExtractedFactUnitName'; -import type { ResExtractedFactReason } from './resExtractedFactReason'; +import type { ResExtractedFactUnitName } from "./resExtractedFactUnitName"; +import type { ResExtractedFactReason } from "./resExtractedFactReason"; export interface ResExtractedFact { key?: string; diff --git a/solution/frontend/src/api/generated/model/resFact.ts b/solution/frontend/src/api/generated/model/resFact.ts index 6e0f80c..9a430e2 100644 --- a/solution/frontend/src/api/generated/model/resFact.ts +++ b/solution/frontend/src/api/generated/model/resFact.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResFactMsg } from './resFactMsg'; -import type { ResFactFact } from './resFactFact'; -import type { ResFactOutcome } from './resFactOutcome'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResFactMsg } from "./resFactMsg"; +import type { ResFactFact } from "./resFactFact"; +import type { ResFactOutcome } from "./resFactOutcome"; export interface ResFact { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resFactFact.ts b/solution/frontend/src/api/generated/model/resFactFact.ts index e09c9f5..55a63aa 100644 --- a/solution/frontend/src/api/generated/model/resFactFact.ts +++ b/solution/frontend/src/api/generated/model/resFactFact.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FactData } from './factData'; +import type { FactData } from "./factData"; export type ResFactFact = FactData | null; diff --git a/solution/frontend/src/api/generated/model/resFactList.ts b/solution/frontend/src/api/generated/model/resFactList.ts index ce221be..2c4059f 100644 --- a/solution/frontend/src/api/generated/model/resFactList.ts +++ b/solution/frontend/src/api/generated/model/resFactList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResFactListMsg } from './resFactListMsg'; -import type { FactData } from './factData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResFactListMsg } from "./resFactListMsg"; +import type { FactData } from "./factData"; export interface ResFactList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resFactOutcome.ts b/solution/frontend/src/api/generated/model/resFactOutcome.ts index 53f6ea7..608d6a7 100644 --- a/solution/frontend/src/api/generated/model/resFactOutcome.ts +++ b/solution/frontend/src/api/generated/model/resFactOutcome.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FactWriteOutcome } from './factWriteOutcome'; +import type { FactWriteOutcome } from "./factWriteOutcome"; export type ResFactOutcome = FactWriteOutcome | null; diff --git a/solution/frontend/src/api/generated/model/resFaq.ts b/solution/frontend/src/api/generated/model/resFaq.ts index 5502505..49ec104 100644 --- a/solution/frontend/src/api/generated/model/resFaq.ts +++ b/solution/frontend/src/api/generated/model/resFaq.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResFaqMsg } from './resFaqMsg'; -import type { ResFaqFaq } from './resFaqFaq'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResFaqMsg } from "./resFaqMsg"; +import type { ResFaqFaq } from "./resFaqFaq"; export interface ResFaq { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resFaqFaq.ts b/solution/frontend/src/api/generated/model/resFaqFaq.ts index 76fc798..6585b0d 100644 --- a/solution/frontend/src/api/generated/model/resFaqFaq.ts +++ b/solution/frontend/src/api/generated/model/resFaqFaq.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { FaqData } from './faqData'; +import type { FaqData } from "./faqData"; export type ResFaqFaq = FaqData | null; diff --git a/solution/frontend/src/api/generated/model/resFaqList.ts b/solution/frontend/src/api/generated/model/resFaqList.ts index 765d1d0..4e07f10 100644 --- a/solution/frontend/src/api/generated/model/resFaqList.ts +++ b/solution/frontend/src/api/generated/model/resFaqList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResFaqListMsg } from './resFaqListMsg'; -import type { FaqData } from './faqData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResFaqListMsg } from "./resFaqListMsg"; +import type { FaqData } from "./faqData"; export interface ResFaqList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resJob.ts b/solution/frontend/src/api/generated/model/resJob.ts index 2310d4b..b7b0434 100644 --- a/solution/frontend/src/api/generated/model/resJob.ts +++ b/solution/frontend/src/api/generated/model/resJob.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResJobMsg } from './resJobMsg'; -import type { ResJobJob } from './resJobJob'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResJobMsg } from "./resJobMsg"; +import type { ResJobJob } from "./resJobJob"; /** * 잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다. diff --git a/solution/frontend/src/api/generated/model/resJobJob.ts b/solution/frontend/src/api/generated/model/resJobJob.ts index 138e47a..688ea3c 100644 --- a/solution/frontend/src/api/generated/model/resJobJob.ts +++ b/solution/frontend/src/api/generated/model/resJobJob.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobData } from './jobData'; +import type { JobData } from "./jobData"; export type ResJobJob = JobData | null; diff --git a/solution/frontend/src/api/generated/model/resJobOps.ts b/solution/frontend/src/api/generated/model/resJobOps.ts index c561af5..b0d81b9 100644 --- a/solution/frontend/src/api/generated/model/resJobOps.ts +++ b/solution/frontend/src/api/generated/model/resJobOps.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResJobOpsMsg } from './resJobOpsMsg'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResJobOpsMsg } from "./resJobOpsMsg"; /** * 운영 스냅샷 — 큐 지연·DEAD·좀비 감시용. diff --git a/solution/frontend/src/api/generated/model/resLink.ts b/solution/frontend/src/api/generated/model/resLink.ts index 766394b..68419c8 100644 --- a/solution/frontend/src/api/generated/model/resLink.ts +++ b/solution/frontend/src/api/generated/model/resLink.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResLinkMsg } from './resLinkMsg'; -import type { ResLinkLink } from './resLinkLink'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResLinkMsg } from "./resLinkMsg"; +import type { ResLinkLink } from "./resLinkLink"; export interface ResLink { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resLinkLink.ts b/solution/frontend/src/api/generated/model/resLinkLink.ts index 0fb6701..d0514af 100644 --- a/solution/frontend/src/api/generated/model/resLinkLink.ts +++ b/solution/frontend/src/api/generated/model/resLinkLink.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { LinkData } from './linkData'; +import type { LinkData } from "./linkData"; export type ResLinkLink = LinkData | null; diff --git a/solution/frontend/src/api/generated/model/resLinkList.ts b/solution/frontend/src/api/generated/model/resLinkList.ts index 18e360a..68e1e2f 100644 --- a/solution/frontend/src/api/generated/model/resLinkList.ts +++ b/solution/frontend/src/api/generated/model/resLinkList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResLinkListMsg } from './resLinkListMsg'; -import type { LinkData } from './linkData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResLinkListMsg } from "./resLinkListMsg"; +import type { LinkData } from "./linkData"; export interface ResLinkList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resLocalContentList.ts b/solution/frontend/src/api/generated/model/resLocalContentList.ts index 0f05af2..ddfa218 100644 --- a/solution/frontend/src/api/generated/model/resLocalContentList.ts +++ b/solution/frontend/src/api/generated/model/resLocalContentList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResLocalContentListMsg } from './resLocalContentListMsg'; -import type { LocalContentData } from './localContentData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResLocalContentListMsg } from "./resLocalContentListMsg"; +import type { LocalContentData } from "./localContentData"; export interface ResLocalContentList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resLocalGuide.ts b/solution/frontend/src/api/generated/model/resLocalGuide.ts new file mode 100644 index 0000000..53bfbd3 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuide.ts @@ -0,0 +1,29 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResLocalGuideMsg } from "./resLocalGuideMsg"; +import type { ResLocalGuideAttractionsItem } from "./resLocalGuideAttractionsItem"; +import type { ResLocalGuideRestaurantsItem } from "./resLocalGuideRestaurantsItem"; +import type { ResLocalGuideFestivalsItem } from "./resLocalGuideFestivalsItem"; +import type { ResLocalGuideCoursesItem } from "./resLocalGuideCoursesItem"; +import type { ResLocalGuideItinerariesItem } from "./resLocalGuideItinerariesItem"; +import type { ResLocalGuideSyncedAt } from "./resLocalGuideSyncedAt"; + +/** + * 에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 +(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다. + */ +export interface ResLocalGuide { + result?: ErrorInfo; + msg?: ResLocalGuideMsg; + attractions?: ResLocalGuideAttractionsItem[]; + restaurants?: ResLocalGuideRestaurantsItem[]; + festivals?: ResLocalGuideFestivalsItem[]; + courses?: ResLocalGuideCoursesItem[]; + itineraries?: ResLocalGuideItinerariesItem[]; + synced_at?: ResLocalGuideSyncedAt; +} diff --git a/solution/frontend/src/api/generated/model/resLocalGuideAttractionsItem.ts b/solution/frontend/src/api/generated/model/resLocalGuideAttractionsItem.ts new file mode 100644 index 0000000..85da430 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideAttractionsItem.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideAttractionsItem = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideCoursesItem.ts b/solution/frontend/src/api/generated/model/resLocalGuideCoursesItem.ts new file mode 100644 index 0000000..b749f83 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideCoursesItem.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideCoursesItem = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideFestivalsItem.ts b/solution/frontend/src/api/generated/model/resLocalGuideFestivalsItem.ts new file mode 100644 index 0000000..42b5637 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideFestivalsItem.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideFestivalsItem = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideItinerariesItem.ts b/solution/frontend/src/api/generated/model/resLocalGuideItinerariesItem.ts new file mode 100644 index 0000000..e7ff431 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideItinerariesItem.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideItinerariesItem = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideMsg.ts b/solution/frontend/src/api/generated/model/resLocalGuideMsg.ts new file mode 100644 index 0000000..9f7e1c4 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideRestaurantsItem.ts b/solution/frontend/src/api/generated/model/resLocalGuideRestaurantsItem.ts new file mode 100644 index 0000000..095059c --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideRestaurantsItem.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideRestaurantsItem = { [key: string]: unknown }; diff --git a/solution/frontend/src/api/generated/model/resLocalGuideSyncedAt.ts b/solution/frontend/src/api/generated/model/resLocalGuideSyncedAt.ts new file mode 100644 index 0000000..ed4aaec --- /dev/null +++ b/solution/frontend/src/api/generated/model/resLocalGuideSyncedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResLocalGuideSyncedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/resLogin.ts b/solution/frontend/src/api/generated/model/resLogin.ts index edb1605..b2e74cb 100644 --- a/solution/frontend/src/api/generated/model/resLogin.ts +++ b/solution/frontend/src/api/generated/model/resLogin.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResLoginMsg } from './resLoginMsg'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResLoginMsg } from "./resLoginMsg"; export interface ResLogin { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resMe.ts b/solution/frontend/src/api/generated/model/resMe.ts index 2441fe4..1e55fc2 100644 --- a/solution/frontend/src/api/generated/model/resMe.ts +++ b/solution/frontend/src/api/generated/model/resMe.ts @@ -4,13 +4,13 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResMeMsg } from './resMeMsg'; -import type { ResMeName } from './resMeName'; -import type { ResMeEmail } from './resMeEmail'; -import type { ResMeContactNumber } from './resMeContactNumber'; -import type { UserRole } from './userRole'; -import type { AuthProvider } from './authProvider'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResMeMsg } from "./resMeMsg"; +import type { ResMeName } from "./resMeName"; +import type { ResMeEmail } from "./resMeEmail"; +import type { ResMeContactNumber } from "./resMeContactNumber"; +import type { UserRole } from "./userRole"; +import type { AuthProvider } from "./authProvider"; export interface ResMe { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resMediaList.ts b/solution/frontend/src/api/generated/model/resMediaList.ts index 53900b8..3065836 100644 --- a/solution/frontend/src/api/generated/model/resMediaList.ts +++ b/solution/frontend/src/api/generated/model/resMediaList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResMediaListMsg } from './resMediaListMsg'; -import type { MediaData } from './mediaData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResMediaListMsg } from "./resMediaListMsg"; +import type { MediaData } from "./mediaData"; export interface ResMediaList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resMySites.ts b/solution/frontend/src/api/generated/model/resMySites.ts index f8c5655..d021424 100644 --- a/solution/frontend/src/api/generated/model/resMySites.ts +++ b/solution/frontend/src/api/generated/model/resMySites.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResMySitesMsg } from './resMySitesMsg'; -import type { MySiteData } from './mySiteData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResMySitesMsg } from "./resMySitesMsg"; +import type { MySiteData } from "./mySiteData"; export interface ResMySites { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resPlace.ts b/solution/frontend/src/api/generated/model/resPlace.ts index 8cb6581..d15a911 100644 --- a/solution/frontend/src/api/generated/model/resPlace.ts +++ b/solution/frontend/src/api/generated/model/resPlace.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResPlaceMsg } from './resPlaceMsg'; -import type { ResPlacePlace } from './resPlacePlace'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResPlaceMsg } from "./resPlaceMsg"; +import type { ResPlacePlace } from "./resPlacePlace"; export interface ResPlace { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resPlaceContentList.ts b/solution/frontend/src/api/generated/model/resPlaceContentList.ts new file mode 100644 index 0000000..9171ddd --- /dev/null +++ b/solution/frontend/src/api/generated/model/resPlaceContentList.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResPlaceContentListMsg } from "./resPlaceContentListMsg"; +import type { PlaceContentData } from "./placeContentData"; + +export interface ResPlaceContentList { + result?: ErrorInfo; + msg?: ResPlaceContentListMsg; + contents?: PlaceContentData[]; +} diff --git a/solution/frontend/src/api/generated/model/resPlaceContentListMsg.ts b/solution/frontend/src/api/generated/model/resPlaceContentListMsg.ts new file mode 100644 index 0000000..d0faed4 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resPlaceContentListMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResPlaceContentListMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/resPlaceList.ts b/solution/frontend/src/api/generated/model/resPlaceList.ts index 724fd05..0991627 100644 --- a/solution/frontend/src/api/generated/model/resPlaceList.ts +++ b/solution/frontend/src/api/generated/model/resPlaceList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResPlaceListMsg } from './resPlaceListMsg'; -import type { PlaceData } from './placeData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResPlaceListMsg } from "./resPlaceListMsg"; +import type { PlaceData } from "./placeData"; export interface ResPlaceList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resPlacePlace.ts b/solution/frontend/src/api/generated/model/resPlacePlace.ts index d27c75f..dcb3f70 100644 --- a/solution/frontend/src/api/generated/model/resPlacePlace.ts +++ b/solution/frontend/src/api/generated/model/resPlacePlace.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceData } from './placeData'; +import type { PlaceData } from "./placeData"; export type ResPlacePlace = PlaceData | null; diff --git a/solution/frontend/src/api/generated/model/resPlaceSearch.ts b/solution/frontend/src/api/generated/model/resPlaceSearch.ts index beb75cc..0481b69 100644 --- a/solution/frontend/src/api/generated/model/resPlaceSearch.ts +++ b/solution/frontend/src/api/generated/model/resPlaceSearch.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResPlaceSearchMsg } from './resPlaceSearchMsg'; -import type { ResPlaceSearchSource } from './resPlaceSearchSource'; -import type { PlaceSearchItem } from './placeSearchItem'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResPlaceSearchMsg } from "./resPlaceSearchMsg"; +import type { ResPlaceSearchSource } from "./resPlaceSearchSource"; +import type { PlaceSearchItem } from "./placeSearchItem"; /** * 상호명 공개 검색 결과. diff --git a/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts b/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts index c1ea375..e89c4df 100644 --- a/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts +++ b/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ExternalPlaceSource } from './externalPlaceSource'; +import type { ExternalPlaceSource } from "./externalPlaceSource"; export type ResPlaceSearchSource = ExternalPlaceSource | null; diff --git a/solution/frontend/src/api/generated/model/resPublishLogs.ts b/solution/frontend/src/api/generated/model/resPublishLogs.ts index 1d3462f..42694ba 100644 --- a/solution/frontend/src/api/generated/model/resPublishLogs.ts +++ b/solution/frontend/src/api/generated/model/resPublishLogs.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResPublishLogsMsg } from './resPublishLogsMsg'; -import type { PublishLogData } from './publishLogData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResPublishLogsMsg } from "./resPublishLogsMsg"; +import type { PublishLogData } from "./publishLogData"; export interface ResPublishLogs { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resRefreshToken.ts b/solution/frontend/src/api/generated/model/resRefreshToken.ts index dd82f8d..ec10f0c 100644 --- a/solution/frontend/src/api/generated/model/resRefreshToken.ts +++ b/solution/frontend/src/api/generated/model/resRefreshToken.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResRefreshTokenMsg } from './resRefreshTokenMsg'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResRefreshTokenMsg } from "./resRefreshTokenMsg"; export interface ResRefreshToken { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSeoAudit.ts b/solution/frontend/src/api/generated/model/resSeoAudit.ts index 9122c3d..9b707bf 100644 --- a/solution/frontend/src/api/generated/model/resSeoAudit.ts +++ b/solution/frontend/src/api/generated/model/resSeoAudit.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSeoAuditMsg } from './resSeoAuditMsg'; -import type { AuditCheckData } from './auditCheckData'; -import type { ResSeoAuditSummary } from './resSeoAuditSummary'; -import type { ResSeoAuditVisibility } from './resSeoAuditVisibility'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResSeoAuditMsg } from "./resSeoAuditMsg"; +import type { AuditCheckData } from "./auditCheckData"; +import type { ResSeoAuditSummary } from "./resSeoAuditSummary"; +import type { ResSeoAuditVisibility } from "./resSeoAuditVisibility"; export interface ResSeoAudit { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSeoAuditVisibility.ts b/solution/frontend/src/api/generated/model/resSeoAuditVisibility.ts index 5182aa4..d33aca0 100644 --- a/solution/frontend/src/api/generated/model/resSeoAuditVisibility.ts +++ b/solution/frontend/src/api/generated/model/resSeoAuditVisibility.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResSeoAuditVisibility = {[key: string]: number}; +export type ResSeoAuditVisibility = { [key: string]: number }; diff --git a/solution/frontend/src/api/generated/model/resShowcase.ts b/solution/frontend/src/api/generated/model/resShowcase.ts index 7c01a7f..905d626 100644 --- a/solution/frontend/src/api/generated/model/resShowcase.ts +++ b/solution/frontend/src/api/generated/model/resShowcase.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResShowcaseMsg } from './resShowcaseMsg'; -import type { ShowcaseItem } from './showcaseItem'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResShowcaseMsg } from "./resShowcaseMsg"; +import type { ShowcaseItem } from "./showcaseItem"; export interface ResShowcase { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSite.ts b/solution/frontend/src/api/generated/model/resSite.ts index 1a2a3a2..7ee71c9 100644 --- a/solution/frontend/src/api/generated/model/resSite.ts +++ b/solution/frontend/src/api/generated/model/resSite.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSiteMsg } from './resSiteMsg'; -import type { ResSiteSite } from './resSiteSite'; -import type { ResSiteCurrentVersion } from './resSiteCurrentVersion'; -import type { RenderStatusData } from './renderStatusData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResSiteMsg } from "./resSiteMsg"; +import type { ResSiteSite } from "./resSiteSite"; +import type { ResSiteCurrentVersion } from "./resSiteCurrentVersion"; +import type { RenderStatusData } from "./renderStatusData"; export interface ResSite { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSiteCurrentVersion.ts b/solution/frontend/src/api/generated/model/resSiteCurrentVersion.ts index a4f3a23..970301f 100644 --- a/solution/frontend/src/api/generated/model/resSiteCurrentVersion.ts +++ b/solution/frontend/src/api/generated/model/resSiteCurrentVersion.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteVersionData } from './siteVersionData'; +import type { SiteVersionData } from "./siteVersionData"; export type ResSiteCurrentVersion = SiteVersionData | null; diff --git a/solution/frontend/src/api/generated/model/resSiteSite.ts b/solution/frontend/src/api/generated/model/resSiteSite.ts index 3d2e760..00107aa 100644 --- a/solution/frontend/src/api/generated/model/resSiteSite.ts +++ b/solution/frontend/src/api/generated/model/resSiteSite.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteData } from './siteData'; +import type { SiteData } from "./siteData"; export type ResSiteSite = SiteData | null; diff --git a/solution/frontend/src/api/generated/model/resSiteSlug.ts b/solution/frontend/src/api/generated/model/resSiteSlug.ts index 538e0ca..45464bc 100644 --- a/solution/frontend/src/api/generated/model/resSiteSlug.ts +++ b/solution/frontend/src/api/generated/model/resSiteSlug.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSiteSlugMsg } from './resSiteSlugMsg'; -import type { ResSiteSlugSite } from './resSiteSlugSite'; -import type { ResSiteSlugReason } from './resSiteSlugReason'; -import type { ResSiteSlugSuggestion } from './resSiteSlugSuggestion'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResSiteSlugMsg } from "./resSiteSlugMsg"; +import type { ResSiteSlugSite } from "./resSiteSlugSite"; +import type { ResSiteSlugReason } from "./resSiteSlugReason"; +import type { ResSiteSlugSuggestion } from "./resSiteSlugSuggestion"; /** * 주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다. diff --git a/solution/frontend/src/api/generated/model/resSiteSlugSite.ts b/solution/frontend/src/api/generated/model/resSiteSlugSite.ts index b954cfb..192eb96 100644 --- a/solution/frontend/src/api/generated/model/resSiteSlugSite.ts +++ b/solution/frontend/src/api/generated/model/resSiteSlugSite.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteData } from './siteData'; +import type { SiteData } from "./siteData"; export type ResSiteSlugSite = SiteData | null; diff --git a/solution/frontend/src/api/generated/model/resSiteVersions.ts b/solution/frontend/src/api/generated/model/resSiteVersions.ts index d9a7237..192b8ae 100644 --- a/solution/frontend/src/api/generated/model/resSiteVersions.ts +++ b/solution/frontend/src/api/generated/model/resSiteVersions.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSiteVersionsMsg } from './resSiteVersionsMsg'; -import type { SiteVersionData } from './siteVersionData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResSiteVersionsMsg } from "./resSiteVersionsMsg"; +import type { SiteVersionData } from "./siteVersionData"; export interface ResSiteVersions { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSlugCheck.ts b/solution/frontend/src/api/generated/model/resSlugCheck.ts index 6a53f5d..2b8fd5e 100644 --- a/solution/frontend/src/api/generated/model/resSlugCheck.ts +++ b/solution/frontend/src/api/generated/model/resSlugCheck.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSlugCheckMsg } from './resSlugCheckMsg'; -import type { ResSlugCheckReason } from './resSlugCheckReason'; -import type { ResSlugCheckSuggestion } from './resSlugCheckSuggestion'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResSlugCheckMsg } from "./resSlugCheckMsg"; +import type { ResSlugCheckReason } from "./resSlugCheckReason"; +import type { ResSlugCheckSuggestion } from "./resSlugCheckSuggestion"; /** * 주소 사용 가능 확인. UI 가 타이핑 중에 호출한다. diff --git a/solution/frontend/src/api/generated/model/resStartBuild.ts b/solution/frontend/src/api/generated/model/resStartBuild.ts index ff52d77..77ecd9f 100644 --- a/solution/frontend/src/api/generated/model/resStartBuild.ts +++ b/solution/frontend/src/api/generated/model/resStartBuild.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResStartBuildMsg } from './resStartBuildMsg'; -import type { ResStartBuildJobId } from './resStartBuildJobId'; -import type { ResStartBuildStatus } from './resStartBuildStatus'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResStartBuildMsg } from "./resStartBuildMsg"; +import type { ResStartBuildJobId } from "./resStartBuildJobId"; +import type { ResStartBuildStatus } from "./resStartBuildStatus"; export interface ResStartBuild { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resStartBuildStatus.ts b/solution/frontend/src/api/generated/model/resStartBuildStatus.ts index 30a76ae..23ef785 100644 --- a/solution/frontend/src/api/generated/model/resStartBuildStatus.ts +++ b/solution/frontend/src/api/generated/model/resStartBuildStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobStatus } from './jobStatus'; +import type { JobStatus } from "./jobStatus"; export type ResStartBuildStatus = JobStatus | null; diff --git a/solution/frontend/src/api/generated/model/resStartCollect.ts b/solution/frontend/src/api/generated/model/resStartCollect.ts index 9e121bb..099a4cf 100644 --- a/solution/frontend/src/api/generated/model/resStartCollect.ts +++ b/solution/frontend/src/api/generated/model/resStartCollect.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResStartCollectMsg } from './resStartCollectMsg'; -import type { ResStartCollectJobId } from './resStartCollectJobId'; -import type { ResStartCollectStatus } from './resStartCollectStatus'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResStartCollectMsg } from "./resStartCollectMsg"; +import type { ResStartCollectJobId } from "./resStartCollectJobId"; +import type { ResStartCollectStatus } from "./resStartCollectStatus"; /** * 수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다. diff --git a/solution/frontend/src/api/generated/model/resStartCollectStatus.ts b/solution/frontend/src/api/generated/model/resStartCollectStatus.ts index 4490db5..29dbb67 100644 --- a/solution/frontend/src/api/generated/model/resStartCollectStatus.ts +++ b/solution/frontend/src/api/generated/model/resStartCollectStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobStatus } from './jobStatus'; +import type { JobStatus } from "./jobStatus"; export type ResStartCollectStatus = JobStatus | null; diff --git a/solution/frontend/src/api/generated/model/resStartCopy.ts b/solution/frontend/src/api/generated/model/resStartCopy.ts index 7793a52..bc2bdf7 100644 --- a/solution/frontend/src/api/generated/model/resStartCopy.ts +++ b/solution/frontend/src/api/generated/model/resStartCopy.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResStartCopyMsg } from './resStartCopyMsg'; -import type { ResStartCopyJobId } from './resStartCopyJobId'; -import type { ResStartCopyStatus } from './resStartCopyStatus'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResStartCopyMsg } from "./resStartCopyMsg"; +import type { ResStartCopyJobId } from "./resStartCopyJobId"; +import type { ResStartCopyStatus } from "./resStartCopyStatus"; export interface ResStartCopy { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resStartCopyStatus.ts b/solution/frontend/src/api/generated/model/resStartCopyStatus.ts index 426b2da..baaeda6 100644 --- a/solution/frontend/src/api/generated/model/resStartCopyStatus.ts +++ b/solution/frontend/src/api/generated/model/resStartCopyStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobStatus } from './jobStatus'; +import type { JobStatus } from "./jobStatus"; export type ResStartCopyStatus = JobStatus | null; diff --git a/solution/frontend/src/api/generated/model/resStartVision.ts b/solution/frontend/src/api/generated/model/resStartVision.ts index 9eb9fe4..910098f 100644 --- a/solution/frontend/src/api/generated/model/resStartVision.ts +++ b/solution/frontend/src/api/generated/model/resStartVision.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResStartVisionMsg } from './resStartVisionMsg'; -import type { ResStartVisionJobId } from './resStartVisionJobId'; -import type { ResStartVisionStatus } from './resStartVisionStatus'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResStartVisionMsg } from "./resStartVisionMsg"; +import type { ResStartVisionJobId } from "./resStartVisionJobId"; +import type { ResStartVisionStatus } from "./resStartVisionStatus"; export interface ResStartVision { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resStartVisionStatus.ts b/solution/frontend/src/api/generated/model/resStartVisionStatus.ts index 9d63ad8..9a96af9 100644 --- a/solution/frontend/src/api/generated/model/resStartVisionStatus.ts +++ b/solution/frontend/src/api/generated/model/resStartVisionStatus.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { JobStatus } from './jobStatus'; +import type { JobStatus } from "./jobStatus"; export type ResStartVisionStatus = JobStatus | null; diff --git a/solution/frontend/src/api/generated/model/resSyncFestivals.ts b/solution/frontend/src/api/generated/model/resSyncFestivals.ts deleted file mode 100644 index b04f96b..0000000 --- a/solution/frontend/src/api/generated/model/resSyncFestivals.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ -import type { ErrorInfo } from './errorInfo'; -import type { ResSyncFestivalsMsg } from './resSyncFestivalsMsg'; - -export interface ResSyncFestivals { - result?: ErrorInfo; - msg?: ResSyncFestivalsMsg; - collected?: number; - skipped?: number; -} diff --git a/solution/frontend/src/api/generated/model/resSyncPlace.ts b/solution/frontend/src/api/generated/model/resSyncPlace.ts new file mode 100644 index 0000000..ed54f62 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resSyncPlace.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResSyncPlaceMsg } from "./resSyncPlaceMsg"; + +/** + * 업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지. + */ +export interface ResSyncPlace { + result?: ErrorInfo; + msg?: ResSyncPlaceMsg; + festivals?: number; + attractions?: number; + restaurants?: number; + courses?: number; + changed?: boolean; +} diff --git a/solution/frontend/src/api/generated/model/resSyncPlaceMsg.ts b/solution/frontend/src/api/generated/model/resSyncPlaceMsg.ts new file mode 100644 index 0000000..23d06b4 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resSyncPlaceMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResSyncPlaceMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/resUnit.ts b/solution/frontend/src/api/generated/model/resUnit.ts index d9af559..5fc26f8 100644 --- a/solution/frontend/src/api/generated/model/resUnit.ts +++ b/solution/frontend/src/api/generated/model/resUnit.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResUnitMsg } from './resUnitMsg'; -import type { ResUnitUnit } from './resUnitUnit'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResUnitMsg } from "./resUnitMsg"; +import type { ResUnitUnit } from "./resUnitUnit"; export interface ResUnit { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resUnitList.ts b/solution/frontend/src/api/generated/model/resUnitList.ts index 0999bdf..e1e9af2 100644 --- a/solution/frontend/src/api/generated/model/resUnitList.ts +++ b/solution/frontend/src/api/generated/model/resUnitList.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResUnitListMsg } from './resUnitListMsg'; -import type { UnitData } from './unitData'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResUnitListMsg } from "./resUnitListMsg"; +import type { UnitData } from "./unitData"; export interface ResUnitList { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resUnitUnit.ts b/solution/frontend/src/api/generated/model/resUnitUnit.ts index e7b3068..921a6dd 100644 --- a/solution/frontend/src/api/generated/model/resUnitUnit.ts +++ b/solution/frontend/src/api/generated/model/resUnitUnit.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { UnitData } from './unitData'; +import type { UnitData } from "./unitData"; export type ResUnitUnit = UnitData | null; diff --git a/solution/frontend/src/api/generated/model/resVerifyCandidates.ts b/solution/frontend/src/api/generated/model/resVerifyCandidates.ts index 33889e8..4d61d3c 100644 --- a/solution/frontend/src/api/generated/model/resVerifyCandidates.ts +++ b/solution/frontend/src/api/generated/model/resVerifyCandidates.ts @@ -4,11 +4,11 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResVerifyCandidatesMsg } from './resVerifyCandidatesMsg'; -import type { ResVerifyCandidatesSource } from './resVerifyCandidatesSource'; -import type { ResVerifyCandidatesReason } from './resVerifyCandidatesReason'; -import type { PlaceCandidate } from './placeCandidate'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResVerifyCandidatesMsg } from "./resVerifyCandidatesMsg"; +import type { ResVerifyCandidatesSource } from "./resVerifyCandidatesSource"; +import type { ResVerifyCandidatesReason } from "./resVerifyCandidatesReason"; +import type { PlaceCandidate } from "./placeCandidate"; /** * 동일 업소 후보 목록. diff --git a/solution/frontend/src/api/generated/model/resVerifyCandidatesSource.ts b/solution/frontend/src/api/generated/model/resVerifyCandidatesSource.ts index 7e113df..d8f25c4 100644 --- a/solution/frontend/src/api/generated/model/resVerifyCandidatesSource.ts +++ b/solution/frontend/src/api/generated/model/resVerifyCandidatesSource.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ExternalPlaceSource } from './externalPlaceSource'; +import type { ExternalPlaceSource } from "./externalPlaceSource"; export type ResVerifyCandidatesSource = ExternalPlaceSource | null; diff --git a/solution/frontend/src/api/generated/model/resWeather.ts b/solution/frontend/src/api/generated/model/resWeather.ts index 4b4719c..ee118ec 100644 --- a/solution/frontend/src/api/generated/model/resWeather.ts +++ b/solution/frontend/src/api/generated/model/resWeather.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ErrorInfo } from './errorInfo'; -import type { ResWeatherMsg } from './resWeatherMsg'; -import type { ResWeatherWeather } from './resWeatherWeather'; +import type { ErrorInfo } from "./errorInfo"; +import type { ResWeatherMsg } from "./resWeatherMsg"; +import type { ResWeatherWeather } from "./resWeatherWeather"; export interface ResWeather { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resWeatherWeather.ts b/solution/frontend/src/api/generated/model/resWeatherWeather.ts index 48d8de8..220e418 100644 --- a/solution/frontend/src/api/generated/model/resWeatherWeather.ts +++ b/solution/frontend/src/api/generated/model/resWeatherWeather.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { WeatherData } from './weatherData'; +import type { WeatherData } from "./weatherData"; export type ResWeatherWeather = WeatherData | null; diff --git a/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts b/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts index 8d965fd..281bffd 100644 --- a/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts +++ b/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts @@ -6,10 +6,10 @@ */ export type SearchPlacesPublicParams = { -/** - * 상호명 - * @minLength 2 - * @maxLength 100 - */ -q: string; + /** + * 상호명 + * @minLength 2 + * @maxLength 100 + */ + q: string; }; diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts index b6374d3..7aa84ec 100644 --- a/solution/frontend/src/api/generated/model/showcaseItem.ts +++ b/solution/frontend/src/api/generated/model/showcaseItem.ts @@ -4,9 +4,9 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { PlaceCategory } from './placeCategory'; -import type { ShowcaseItemRegion } from './showcaseItemRegion'; -import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl'; +import type { PlaceCategory } from "./placeCategory"; +import type { ShowcaseItemRegion } from "./showcaseItemRegion"; +import type { ShowcaseItemThumbnailUrl } from "./showcaseItemThumbnailUrl"; /** * 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** diff --git a/solution/frontend/src/api/generated/model/siteData.ts b/solution/frontend/src/api/generated/model/siteData.ts index f48299d..cacc8ba 100644 --- a/solution/frontend/src/api/generated/model/siteData.ts +++ b/solution/frontend/src/api/generated/model/siteData.ts @@ -4,13 +4,13 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteStatus } from './siteStatus'; -import type { SiteDataDomain } from './siteDataDomain'; -import type { SiteDataTemplateId } from './siteDataTemplateId'; -import type { SiteDataTheme } from './siteDataTheme'; -import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId'; -import type { SiteDataPublishedAt } from './siteDataPublishedAt'; -import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl'; +import type { SiteStatus } from "./siteStatus"; +import type { SiteDataDomain } from "./siteDataDomain"; +import type { SiteDataTemplateId } from "./siteDataTemplateId"; +import type { SiteDataTheme } from "./siteDataTheme"; +import type { SiteDataCurrentVersionId } from "./siteDataCurrentVersionId"; +import type { SiteDataPublishedAt } from "./siteDataPublishedAt"; +import type { SiteDataThumbnailUrl } from "./siteDataThumbnailUrl"; export interface SiteData { site_id: string; diff --git a/solution/frontend/src/api/generated/model/siteDataTheme.ts b/solution/frontend/src/api/generated/model/siteDataTheme.ts index a06d868..f0aa229 100644 --- a/solution/frontend/src/api/generated/model/siteDataTheme.ts +++ b/solution/frontend/src/api/generated/model/siteDataTheme.ts @@ -4,6 +4,6 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { SiteDataThemeAnyOf } from './siteDataThemeAnyOf'; +import type { SiteDataThemeAnyOf } from "./siteDataThemeAnyOf"; export type SiteDataTheme = SiteDataThemeAnyOf | null; diff --git a/solution/frontend/src/api/generated/model/siteStatus.ts b/solution/frontend/src/api/generated/model/siteStatus.ts index 9716066..e86124e 100644 --- a/solution/frontend/src/api/generated/model/siteStatus.ts +++ b/solution/frontend/src/api/generated/model/siteStatus.ts @@ -8,8 +8,7 @@ /** * sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다. */ -export type SiteStatus = typeof SiteStatus[keyof typeof SiteStatus]; - +export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const SiteStatus = { diff --git a/solution/frontend/src/api/generated/model/siteVersionData.ts b/solution/frontend/src/api/generated/model/siteVersionData.ts index 99a1757..e8c119f 100644 --- a/solution/frontend/src/api/generated/model/siteVersionData.ts +++ b/solution/frontend/src/api/generated/model/siteVersionData.ts @@ -4,10 +4,10 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { BuildStatus } from './buildStatus'; -import type { SiteVersionDataBuildError } from './siteVersionDataBuildError'; -import type { SiteVersionDataBuiltAt } from './siteVersionDataBuiltAt'; -import type { SiteVersionDataCreatedAt } from './siteVersionDataCreatedAt'; +import type { BuildStatus } from "./buildStatus"; +import type { SiteVersionDataBuildError } from "./siteVersionDataBuildError"; +import type { SiteVersionDataBuiltAt } from "./siteVersionDataBuiltAt"; +import type { SiteVersionDataCreatedAt } from "./siteVersionDataCreatedAt"; export interface SiteVersionData { site_version_id: string; diff --git a/solution/frontend/src/api/generated/model/sourceType.ts b/solution/frontend/src/api/generated/model/sourceType.ts index a2ad4d3..7d549c9 100644 --- a/solution/frontend/src/api/generated/model/sourceType.ts +++ b/solution/frontend/src/api/generated/model/sourceType.ts @@ -6,11 +6,10 @@ */ /** - * facts.source_type / media.source_type / place_aliases.source_type 공용 코드값. + * facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. 값이 어디서 왔는지 — 모든 사실은 출처를 갖는다. */ -export type SourceType = typeof SourceType[keyof typeof SourceType]; - +export type SourceType = (typeof SourceType)[keyof typeof SourceType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const SourceType = { @@ -18,4 +17,5 @@ export const SourceType = { API: 2, CRAWL: 3, LLM: 4, + TEMPLATE: 5, } as const; diff --git a/solution/frontend/src/api/generated/model/userRole.ts b/solution/frontend/src/api/generated/model/userRole.ts index b1500c0..83aaea5 100644 --- a/solution/frontend/src/api/generated/model/userRole.ts +++ b/solution/frontend/src/api/generated/model/userRole.ts @@ -10,8 +10,7 @@ 1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정). 개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다. */ -export type UserRole = typeof UserRole[keyof typeof UserRole]; - +export type UserRole = (typeof UserRole)[keyof typeof UserRole]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const UserRole = { diff --git a/solution/frontend/src/api/generated/model/validationError.ts b/solution/frontend/src/api/generated/model/validationError.ts index bfd016e..3354369 100644 --- a/solution/frontend/src/api/generated/model/validationError.ts +++ b/solution/frontend/src/api/generated/model/validationError.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { ValidationErrorLocItem } from './validationErrorLocItem'; -import type { ValidationErrorCtx } from './validationErrorCtx'; +import type { ValidationErrorLocItem } from "./validationErrorLocItem"; +import type { ValidationErrorCtx } from "./validationErrorCtx"; export interface ValidationError { loc: ValidationErrorLocItem[]; diff --git a/solution/frontend/src/api/generated/model/verifyCandidatesParams.ts b/solution/frontend/src/api/generated/model/verifyCandidatesParams.ts index 52dce97..f249d2b 100644 --- a/solution/frontend/src/api/generated/model/verifyCandidatesParams.ts +++ b/solution/frontend/src/api/generated/model/verifyCandidatesParams.ts @@ -6,8 +6,8 @@ */ export type VerifyCandidatesParams = { -/** - * 검색어. 비우면 등록된 상호명 - */ -query?: string | null; + /** + * 검색어. 비우면 등록된 상호명 + */ + query?: string | null; }; diff --git a/solution/frontend/src/api/generated/model/weatherData.ts b/solution/frontend/src/api/generated/model/weatherData.ts index 7660333..b0fe0b0 100644 --- a/solution/frontend/src/api/generated/model/weatherData.ts +++ b/solution/frontend/src/api/generated/model/weatherData.ts @@ -4,8 +4,8 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import type { WeatherDataWindSpeed } from './weatherDataWindSpeed'; -import type { WeatherDataTimezone } from './weatherDataTimezone'; +import type { WeatherDataWindSpeed } from "./weatherDataWindSpeed"; +import type { WeatherDataTimezone } from "./weatherDataTimezone"; export interface WeatherData { temperature: number; diff --git a/solution/frontend/src/api/generated/place/place.ts b/solution/frontend/src/api/generated/place/place.ts index b0e67d7..95ef86b 100644 --- a/solution/frontend/src/api/generated/place/place.ts +++ b/solution/frontend/src/api/generated/place/place.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,8 +17,8 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, @@ -49,1394 +46,2132 @@ import type { ResUnitList, ResVerifyCandidates, SearchPlacesPublicParams, - VerifyCandidatesParams -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + VerifyCandidatesParams, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * @summary 사업장 목록 */ export const listPlaces = ( - params?: ListPlacesParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params?: ListPlacesParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlaceList>( - {url: `/v1/place/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResPlaceList>( + { url: `/v1/place/list`, method: "GET", params, signal }, + options, + ); +}; +export const getListPlacesQueryKey = (params?: ListPlacesParams) => { + return [`/v1/place/list`, ...(params ? [params] : [])] as const; +}; - -export const getListPlacesQueryKey = (params?: ListPlacesParams,) => { - return [ - `/v1/place/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListPlacesQueryOptions = <TData = Awaited<ReturnType<typeof listPlaces>>, TError = void | HTTPValidationError>(params?: ListPlacesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListPlacesQueryOptions = < + TData = Awaited<ReturnType<typeof listPlaces>>, + TError = void | HTTPValidationError, +>( + params?: ListPlacesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListPlacesQueryKey(params); - const queryKey = queryOptions?.queryKey ?? getListPlacesQueryKey(params); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listPlaces>>> = ({ + signal, + }) => listPlaces(params, requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof listPlaces>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listPlaces>>> = ({ signal }) => listPlaces(params, requestOptions, signal); +export type ListPlacesQueryResult = NonNullable< + Awaited<ReturnType<typeof listPlaces>> +>; +export type ListPlacesQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListPlacesQueryResult = NonNullable<Awaited<ReturnType<typeof listPlaces>>> -export type ListPlacesQueryError = void | HTTPValidationError - - -export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TError = void | HTTPValidationError>( - params: undefined | ListPlacesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData>> & Pick< +export function useListPlaces< + TData = Awaited<ReturnType<typeof listPlaces>>, + TError = void | HTTPValidationError, +>( + params: undefined | ListPlacesParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listPlaces>>, TError, Awaited<ReturnType<typeof listPlaces>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TError = void | HTTPValidationError>( - params?: ListPlacesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListPlaces< + TData = Awaited<ReturnType<typeof listPlaces>>, + TError = void | HTTPValidationError, +>( + params?: ListPlacesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listPlaces>>, TError, Awaited<ReturnType<typeof listPlaces>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TError = void | HTTPValidationError>( - params?: ListPlacesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListPlaces< + TData = Awaited<ReturnType<typeof listPlaces>>, + TError = void | HTTPValidationError, +>( + params?: ListPlacesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 사업장 목록 */ -export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TError = void | HTTPValidationError>( - params?: ListPlacesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListPlaces< + TData = Awaited<ReturnType<typeof listPlaces>>, + TError = void | HTTPValidationError, +>( + params?: ListPlacesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listPlaces>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListPlacesQueryOptions(params, options); - const queryOptions = getListPlacesQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. ★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. ★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, 값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다. * @summary 상호명 공개 검색 */ export const searchPlacesPublic = ( - params: SearchPlacesPublicParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params: SearchPlacesPublicParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlaceSearch>( - {url: `/v1/place/search`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResPlaceSearch>( + { url: `/v1/place/search`, method: "GET", params, signal }, + options, + ); +}; - - -export const getSearchPlacesPublicQueryKey = (params?: SearchPlacesPublicParams,) => { - return [ - `/v1/place/search`, ...(params ? [params]: []) - ] as const; - } - - -export const getSearchPlacesPublicQueryOptions = <TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getSearchPlacesPublicQueryKey = ( + params?: SearchPlacesPublicParams, ) => { + return [`/v1/place/search`, ...(params ? [params] : [])] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getSearchPlacesPublicQueryOptions = < + TData = Awaited<ReturnType<typeof searchPlacesPublic>>, + TError = void | HTTPValidationError, +>( + params: SearchPlacesPublicParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params); + const queryKey = + queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params); - + const queryFn: QueryFunction< + Awaited<ReturnType<typeof searchPlacesPublic>> + > = ({ signal }) => searchPlacesPublic(params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof searchPlacesPublic>>> = ({ signal }) => searchPlacesPublic(params, requestOptions, signal); + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - +export type SearchPlacesPublicQueryResult = NonNullable< + Awaited<ReturnType<typeof searchPlacesPublic>> +>; +export type SearchPlacesPublicQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type SearchPlacesPublicQueryResult = NonNullable<Awaited<ReturnType<typeof searchPlacesPublic>>> -export type SearchPlacesPublicQueryError = void | HTTPValidationError - - -export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>( - params: SearchPlacesPublicParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick< +export function useSearchPlacesPublic< + TData = Awaited<ReturnType<typeof searchPlacesPublic>>, + TError = void | HTTPValidationError, +>( + params: SearchPlacesPublicParams, + options: { + query: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof searchPlacesPublic>>, TError, Awaited<ReturnType<typeof searchPlacesPublic>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>( - params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useSearchPlacesPublic< + TData = Awaited<ReturnType<typeof searchPlacesPublic>>, + TError = void | HTTPValidationError, +>( + params: SearchPlacesPublicParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof searchPlacesPublic>>, TError, Awaited<ReturnType<typeof searchPlacesPublic>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>( - params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useSearchPlacesPublic< + TData = Awaited<ReturnType<typeof searchPlacesPublic>>, + TError = void | HTTPValidationError, +>( + params: SearchPlacesPublicParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 상호명 공개 검색 */ -export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>( - params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useSearchPlacesPublic< + TData = Awaited<ReturnType<typeof searchPlacesPublic>>, + TError = void | HTTPValidationError, +>( + params: SearchPlacesPublicParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof searchPlacesPublic>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getSearchPlacesPublicQueryOptions(params, options); - const queryOptions = getSearchPlacesPublicQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다. * @summary 사업장 등록 */ export const createPlace = ( - reqCreatePlace: ReqCreatePlace, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + reqCreatePlace: ReqCreatePlace, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlace>( - {url: `/v1/place`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreatePlace, signal + return customFetch<ResPlace>( + { + url: `/v1/place`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreatePlace, + signal, }, - options); - } - + options, + ); +}; +export const getCreatePlaceMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createPlace>>, + TError, + { data: ReqCreatePlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof createPlace>>, + TError, + { data: ReqCreatePlace }, + TContext +> => { + const mutationKey = ["createPlace"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getCreatePlaceMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlace>>, TError,{data: ReqCreatePlace}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof createPlace>>, TError,{data: ReqCreatePlace}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof createPlace>>, + { data: ReqCreatePlace } + > = (props) => { + const { data } = props ?? {}; -const mutationKey = ['createPlace']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return createPlace(data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type CreatePlaceMutationResult = NonNullable< + Awaited<ReturnType<typeof createPlace>> +>; +export type CreatePlaceMutationBody = ReqCreatePlace; +export type CreatePlaceMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof createPlace>>, {data: ReqCreatePlace}> = (props) => { - const {data} = props ?? {}; - - return createPlace(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreatePlaceMutationResult = NonNullable<Awaited<ReturnType<typeof createPlace>>> - export type CreatePlaceMutationBody = ReqCreatePlace - export type CreatePlaceMutationError = void | HTTPValidationError - - /** +/** * @summary 사업장 등록 */ -export const useCreatePlace = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlace>>, TError,{data: ReqCreatePlace}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof createPlace>>, - TError, - {data: ReqCreatePlace}, - TContext - > => { +export const useCreatePlace = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createPlace>>, + TError, + { data: ReqCreatePlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof createPlace>>, + TError, + { data: ReqCreatePlace }, + TContext +> => { + const mutationOptions = getCreatePlaceMutationOptions(options); - const mutationOptions = getCreatePlaceMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary 사업장 단건 */ export const getPlace = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlace>( - {url: `/v1/place/${placeId}`, method: 'GET', signal - }, - options); - } - + return customFetch<ResPlace>( + { url: `/v1/place/${placeId}`, method: "GET", signal }, + options, + ); +}; +export const getGetPlaceQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}`] as const; +}; - -export const getGetPlaceQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}` - ] as const; - } - - -export const getGetPlaceQueryOptions = <TData = Awaited<ReturnType<typeof getPlace>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetPlaceQueryOptions = < + TData = Awaited<ReturnType<typeof getPlace>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetPlaceQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getGetPlaceQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getPlace>>> = ({ + signal, + }) => getPlace(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getPlace>>> = ({ signal }) => getPlace(placeId, requestOptions, signal); +export type GetPlaceQueryResult = NonNullable< + Awaited<ReturnType<typeof getPlace>> +>; +export type GetPlaceQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetPlaceQueryResult = NonNullable<Awaited<ReturnType<typeof getPlace>>> -export type GetPlaceQueryError = void | HTTPValidationError - - -export function useGetPlace<TData = Awaited<ReturnType<typeof getPlace>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData>> & Pick< +export function useGetPlace< + TData = Awaited<ReturnType<typeof getPlace>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getPlace>>, TError, Awaited<ReturnType<typeof getPlace>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetPlace<TData = Awaited<ReturnType<typeof getPlace>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetPlace< + TData = Awaited<ReturnType<typeof getPlace>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getPlace>>, TError, Awaited<ReturnType<typeof getPlace>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetPlace<TData = Awaited<ReturnType<typeof getPlace>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetPlace< + TData = Awaited<ReturnType<typeof getPlace>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 사업장 단건 */ -export function useGetPlace<TData = Awaited<ReturnType<typeof getPlace>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetPlace< + TData = Awaited<ReturnType<typeof getPlace>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getPlace>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetPlaceQueryOptions(placeId, options); - const queryOptions = getGetPlaceQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * @summary 사업장 수정 */ export const updatePlace = ( - placeId: string, - reqUpdatePlace: ReqUpdatePlace, - options?: SecondParameter<typeof customFetch>,) => { - - - return customFetch<ResPlace>( - {url: `/v1/place/${placeId}`, method: 'PATCH', - headers: {'Content-Type': 'application/json', }, - data: reqUpdatePlace + placeId: string, + reqUpdatePlace: ReqUpdatePlace, + options?: SecondParameter<typeof customFetch>, +) => { + return customFetch<ResPlace>( + { + url: `/v1/place/${placeId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdatePlace, }, - options); - } - + options, + ); +}; +export const getUpdatePlaceMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updatePlace>>, + TError, + { placeId: string; data: ReqUpdatePlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof updatePlace>>, + TError, + { placeId: string; data: ReqUpdatePlace }, + TContext +> => { + const mutationKey = ["updatePlace"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getUpdatePlaceMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updatePlace>>, TError,{placeId: string;data: ReqUpdatePlace}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof updatePlace>>, TError,{placeId: string;data: ReqUpdatePlace}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof updatePlace>>, + { placeId: string; data: ReqUpdatePlace } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['updatePlace']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return updatePlace(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type UpdatePlaceMutationResult = NonNullable< + Awaited<ReturnType<typeof updatePlace>> +>; +export type UpdatePlaceMutationBody = ReqUpdatePlace; +export type UpdatePlaceMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof updatePlace>>, {placeId: string;data: ReqUpdatePlace}> = (props) => { - const {placeId,data} = props ?? {}; - - return updatePlace(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type UpdatePlaceMutationResult = NonNullable<Awaited<ReturnType<typeof updatePlace>>> - export type UpdatePlaceMutationBody = ReqUpdatePlace - export type UpdatePlaceMutationError = void | HTTPValidationError - - /** +/** * @summary 사업장 수정 */ -export const useUpdatePlace = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updatePlace>>, TError,{placeId: string;data: ReqUpdatePlace}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof updatePlace>>, - TError, - {placeId: string;data: ReqUpdatePlace}, - TContext - > => { +export const useUpdatePlace = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof updatePlace>>, + TError, + { placeId: string; data: ReqUpdatePlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof updatePlace>>, + TError, + { placeId: string; data: ReqUpdatePlace }, + TContext +> => { + const mutationOptions = getUpdatePlaceMutationOptions(options); - const mutationOptions = getUpdatePlaceMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary 사업장 삭제 */ export const deletePlace = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,) => { - - - return customFetch<ResPlace>( - {url: `/v1/place/${placeId}`, method: 'DELETE' - }, - options); - } - + placeId: string, + options?: SecondParameter<typeof customFetch>, +) => { + return customFetch<ResPlace>( + { url: `/v1/place/${placeId}`, method: "DELETE" }, + options, + ); +}; +export const getDeletePlaceMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof deletePlace>>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof deletePlace>>, + TError, + { placeId: string }, + TContext +> => { + const mutationKey = ["deletePlace"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getDeletePlaceMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deletePlace>>, TError,{placeId: string}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof deletePlace>>, TError,{placeId: string}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof deletePlace>>, + { placeId: string } + > = (props) => { + const { placeId } = props ?? {}; -const mutationKey = ['deletePlace']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return deletePlace(placeId, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type DeletePlaceMutationResult = NonNullable< + Awaited<ReturnType<typeof deletePlace>> +>; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof deletePlace>>, {placeId: string}> = (props) => { - const {placeId} = props ?? {}; +export type DeletePlaceMutationError = void | HTTPValidationError; - return deletePlace(placeId,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type DeletePlaceMutationResult = NonNullable<Awaited<ReturnType<typeof deletePlace>>> - - export type DeletePlaceMutationError = void | HTTPValidationError - - /** +/** * @summary 사업장 삭제 */ -export const useDeletePlace = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deletePlace>>, TError,{placeId: string}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof deletePlace>>, - TError, - {placeId: string}, - TContext - > => { +export const useDeletePlace = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof deletePlace>>, + TError, + { placeId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof deletePlace>>, + TError, + { placeId: string }, + TContext +> => { + const mutationOptions = getDeletePlaceMutationOptions(options); - const mutationOptions = getDeletePlaceMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary 진행 중인 수집 잡 */ export const getActiveCollect = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResJob>( - {url: `/v1/place/${placeId}/collect/active`, method: 'GET', signal - }, - options); - } - + return customFetch<ResJob>( + { url: `/v1/place/${placeId}/collect/active`, method: "GET", signal }, + options, + ); +}; +export const getGetActiveCollectQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/collect/active`] as const; +}; - -export const getGetActiveCollectQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/collect/active` - ] as const; - } - - -export const getGetActiveCollectQueryOptions = <TData = Awaited<ReturnType<typeof getActiveCollect>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetActiveCollectQueryOptions = < + TData = Awaited<ReturnType<typeof getActiveCollect>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = + queryOptions?.queryKey ?? getGetActiveCollectQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getGetActiveCollectQueryKey(placeId); + const queryFn: QueryFunction< + Awaited<ReturnType<typeof getActiveCollect>> + > = ({ signal }) => getActiveCollect(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getActiveCollect>>> = ({ signal }) => getActiveCollect(placeId, requestOptions, signal); +export type GetActiveCollectQueryResult = NonNullable< + Awaited<ReturnType<typeof getActiveCollect>> +>; +export type GetActiveCollectQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetActiveCollectQueryResult = NonNullable<Awaited<ReturnType<typeof getActiveCollect>>> -export type GetActiveCollectQueryError = void | HTTPValidationError - - -export function useGetActiveCollect<TData = Awaited<ReturnType<typeof getActiveCollect>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData>> & Pick< +export function useGetActiveCollect< + TData = Awaited<ReturnType<typeof getActiveCollect>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getActiveCollect>>, TError, Awaited<ReturnType<typeof getActiveCollect>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetActiveCollect<TData = Awaited<ReturnType<typeof getActiveCollect>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetActiveCollect< + TData = Awaited<ReturnType<typeof getActiveCollect>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getActiveCollect>>, TError, Awaited<ReturnType<typeof getActiveCollect>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetActiveCollect<TData = Awaited<ReturnType<typeof getActiveCollect>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetActiveCollect< + TData = Awaited<ReturnType<typeof getActiveCollect>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 진행 중인 수집 잡 */ -export function useGetActiveCollect<TData = Awaited<ReturnType<typeof getActiveCollect>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActiveCollect>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetActiveCollect< + TData = Awaited<ReturnType<typeof getActiveCollect>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof getActiveCollect>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetActiveCollectQueryOptions(placeId, options); - const queryOptions = getGetActiveCollectQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 카카오 로컬 조회 결과를 박제해 동일 업소를 확정한다. ★ 이걸 통과해야 수집이 열린다. * @summary 동일 업소 검증 */ export const verifyPlace = ( - placeId: string, - reqVerifyPlace: ReqVerifyPlace, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqVerifyPlace: ReqVerifyPlace, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlace>( - {url: `/v1/place/${placeId}/verify`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqVerifyPlace, signal + return customFetch<ResPlace>( + { + url: `/v1/place/${placeId}/verify`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqVerifyPlace, + signal, }, - options); - } - + options, + ); +}; +export const getVerifyPlaceMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof verifyPlace>>, + TError, + { placeId: string; data: ReqVerifyPlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof verifyPlace>>, + TError, + { placeId: string; data: ReqVerifyPlace }, + TContext +> => { + const mutationKey = ["verifyPlace"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getVerifyPlaceMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyPlace>>, TError,{placeId: string;data: ReqVerifyPlace}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof verifyPlace>>, TError,{placeId: string;data: ReqVerifyPlace}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof verifyPlace>>, + { placeId: string; data: ReqVerifyPlace } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['verifyPlace']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return verifyPlace(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type VerifyPlaceMutationResult = NonNullable< + Awaited<ReturnType<typeof verifyPlace>> +>; +export type VerifyPlaceMutationBody = ReqVerifyPlace; +export type VerifyPlaceMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof verifyPlace>>, {placeId: string;data: ReqVerifyPlace}> = (props) => { - const {placeId,data} = props ?? {}; - - return verifyPlace(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type VerifyPlaceMutationResult = NonNullable<Awaited<ReturnType<typeof verifyPlace>>> - export type VerifyPlaceMutationBody = ReqVerifyPlace - export type VerifyPlaceMutationError = void | HTTPValidationError - - /** +/** * @summary 동일 업소 검증 */ -export const useVerifyPlace = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyPlace>>, TError,{placeId: string;data: ReqVerifyPlace}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof verifyPlace>>, - TError, - {placeId: string;data: ReqVerifyPlace}, - TContext - > => { +export const useVerifyPlace = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof verifyPlace>>, + TError, + { placeId: string; data: ReqVerifyPlace }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof verifyPlace>>, + TError, + { placeId: string; data: ReqVerifyPlace }, + TContext +> => { + const mutationOptions = getVerifyPlaceMutationOptions(options); - const mutationOptions = getVerifyPlaceMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 사장님이 붙여넣은 네이버 플레이스 주소로 상호·주소·좌표를 읽어 확정하고, 그 URL 을 수집 채널로 등록·확정한다. ★ 상호 검색이 실패하는 가게(동명·지점명 표기 차이)를 위한 확실한 경로다. * @summary 네이버 플레이스 URL 로 동일 업소 확정 */ export const verifyPlaceByUrl = ( - placeId: string, - reqVerifyPlaceByUrl: ReqVerifyPlaceByUrl, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqVerifyPlaceByUrl: ReqVerifyPlaceByUrl, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPlace>( - {url: `/v1/place/${placeId}/verify/by-url`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqVerifyPlaceByUrl, signal + return customFetch<ResPlace>( + { + url: `/v1/place/${placeId}/verify/by-url`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqVerifyPlaceByUrl, + signal, }, - options); - } - + options, + ); +}; +export const getVerifyPlaceByUrlMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof verifyPlaceByUrl>>, + TError, + { placeId: string; data: ReqVerifyPlaceByUrl }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof verifyPlaceByUrl>>, + TError, + { placeId: string; data: ReqVerifyPlaceByUrl }, + TContext +> => { + const mutationKey = ["verifyPlaceByUrl"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getVerifyPlaceByUrlMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyPlaceByUrl>>, TError,{placeId: string;data: ReqVerifyPlaceByUrl}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof verifyPlaceByUrl>>, TError,{placeId: string;data: ReqVerifyPlaceByUrl}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof verifyPlaceByUrl>>, + { placeId: string; data: ReqVerifyPlaceByUrl } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['verifyPlaceByUrl']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return verifyPlaceByUrl(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type VerifyPlaceByUrlMutationResult = NonNullable< + Awaited<ReturnType<typeof verifyPlaceByUrl>> +>; +export type VerifyPlaceByUrlMutationBody = ReqVerifyPlaceByUrl; +export type VerifyPlaceByUrlMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof verifyPlaceByUrl>>, {placeId: string;data: ReqVerifyPlaceByUrl}> = (props) => { - const {placeId,data} = props ?? {}; - - return verifyPlaceByUrl(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type VerifyPlaceByUrlMutationResult = NonNullable<Awaited<ReturnType<typeof verifyPlaceByUrl>>> - export type VerifyPlaceByUrlMutationBody = ReqVerifyPlaceByUrl - export type VerifyPlaceByUrlMutationError = void | HTTPValidationError - - /** +/** * @summary 네이버 플레이스 URL 로 동일 업소 확정 */ -export const useVerifyPlaceByUrl = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyPlaceByUrl>>, TError,{placeId: string;data: ReqVerifyPlaceByUrl}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof verifyPlaceByUrl>>, - TError, - {placeId: string;data: ReqVerifyPlaceByUrl}, - TContext - > => { +export const useVerifyPlaceByUrl = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof verifyPlaceByUrl>>, + TError, + { placeId: string; data: ReqVerifyPlaceByUrl }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof verifyPlaceByUrl>>, + TError, + { placeId: string; data: ReqVerifyPlaceByUrl }, + TContext +> => { + const mutationOptions = getVerifyPlaceByUrlMutationOptions(options); - const mutationOptions = getVerifyPlaceByUrlMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * @summary 하위 단위 목록(객실·메뉴·프로그램) */ export const listUnits = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResUnitList>( - {url: `/v1/place/${placeId}/unit/list`, method: 'GET', signal - }, - options); - } - + return customFetch<ResUnitList>( + { url: `/v1/place/${placeId}/unit/list`, method: "GET", signal }, + options, + ); +}; +export const getListUnitsQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/unit/list`] as const; +}; - -export const getListUnitsQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/unit/list` - ] as const; - } - - -export const getListUnitsQueryOptions = <TData = Awaited<ReturnType<typeof listUnits>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListUnitsQueryOptions = < + TData = Awaited<ReturnType<typeof listUnits>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListUnitsQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getListUnitsQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listUnits>>> = ({ + signal, + }) => listUnits(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listUnits>>> = ({ signal }) => listUnits(placeId, requestOptions, signal); +export type ListUnitsQueryResult = NonNullable< + Awaited<ReturnType<typeof listUnits>> +>; +export type ListUnitsQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListUnitsQueryResult = NonNullable<Awaited<ReturnType<typeof listUnits>>> -export type ListUnitsQueryError = void | HTTPValidationError - - -export function useListUnits<TData = Awaited<ReturnType<typeof listUnits>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData>> & Pick< +export function useListUnits< + TData = Awaited<ReturnType<typeof listUnits>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listUnits>>, TError, Awaited<ReturnType<typeof listUnits>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListUnits<TData = Awaited<ReturnType<typeof listUnits>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListUnits< + TData = Awaited<ReturnType<typeof listUnits>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listUnits>>, TError, Awaited<ReturnType<typeof listUnits>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListUnits<TData = Awaited<ReturnType<typeof listUnits>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListUnits< + TData = Awaited<ReturnType<typeof listUnits>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 하위 단위 목록(객실·메뉴·프로그램) */ -export function useListUnits<TData = Awaited<ReturnType<typeof listUnits>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListUnits< + TData = Awaited<ReturnType<typeof listUnits>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listUnits>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListUnitsQueryOptions(placeId, options); - const queryOptions = getListUnitsQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * @summary 하위 단위 등록 */ export const createUnit = ( - placeId: string, - reqCreateUnit: ReqCreateUnit, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqCreateUnit: ReqCreateUnit, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResUnit>( - {url: `/v1/place/${placeId}/unit`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateUnit, signal + return customFetch<ResUnit>( + { + url: `/v1/place/${placeId}/unit`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateUnit, + signal, }, - options); - } - + options, + ); +}; +export const getCreateUnitMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createUnit>>, + TError, + { placeId: string; data: ReqCreateUnit }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof createUnit>>, + TError, + { placeId: string; data: ReqCreateUnit }, + TContext +> => { + const mutationKey = ["createUnit"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getCreateUnitMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUnit>>, TError,{placeId: string;data: ReqCreateUnit}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof createUnit>>, TError,{placeId: string;data: ReqCreateUnit}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof createUnit>>, + { placeId: string; data: ReqCreateUnit } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['createUnit']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return createUnit(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type CreateUnitMutationResult = NonNullable< + Awaited<ReturnType<typeof createUnit>> +>; +export type CreateUnitMutationBody = ReqCreateUnit; +export type CreateUnitMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof createUnit>>, {placeId: string;data: ReqCreateUnit}> = (props) => { - const {placeId,data} = props ?? {}; - - return createUnit(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateUnitMutationResult = NonNullable<Awaited<ReturnType<typeof createUnit>>> - export type CreateUnitMutationBody = ReqCreateUnit - export type CreateUnitMutationError = void | HTTPValidationError - - /** +/** * @summary 하위 단위 등록 */ -export const useCreateUnit = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUnit>>, TError,{placeId: string;data: ReqCreateUnit}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof createUnit>>, - TError, - {placeId: string;data: ReqCreateUnit}, - TContext - > => { +export const useCreateUnit = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createUnit>>, + TError, + { placeId: string; data: ReqCreateUnit }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof createUnit>>, + TError, + { placeId: string; data: ReqCreateUnit }, + TContext +> => { + const mutationOptions = getCreateUnitMutationOptions(options); - const mutationOptions = getCreateUnitMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * confirmed_only=true 면 크롤링 대상(확정된 URL)만. * @summary 채널 URL 목록 */ export const listLinks = ( - placeId: string, - params?: ListLinksParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params?: ListLinksParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLinkList>( - {url: `/v1/place/${placeId}/link/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResLinkList>( + { url: `/v1/place/${placeId}/link/list`, method: "GET", params, signal }, + options, + ); +}; - - -export const getListLinksQueryKey = (placeId?: string, - params?: ListLinksParams,) => { - return [ - `/v1/place/${placeId}/link/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListLinksQueryOptions = <TData = Awaited<ReturnType<typeof listLinks>>, TError = void | HTTPValidationError>(placeId: string, - params?: ListLinksParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListLinksQueryKey = ( + placeId?: string, + params?: ListLinksParams, ) => { + return [ + `/v1/place/${placeId}/link/list`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getListLinksQueryOptions = < + TData = Awaited<ReturnType<typeof listLinks>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListLinksParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListLinksQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getListLinksQueryKey(placeId, params); - + const queryFn: QueryFunction<Awaited<ReturnType<typeof listLinks>>> = ({ + signal, + }) => listLinks(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof listLinks>>> = ({ signal }) => listLinks(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - +export type ListLinksQueryResult = NonNullable< + Awaited<ReturnType<typeof listLinks>> +>; +export type ListLinksQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListLinksQueryResult = NonNullable<Awaited<ReturnType<typeof listLinks>>> -export type ListLinksQueryError = void | HTTPValidationError - - -export function useListLinks<TData = Awaited<ReturnType<typeof listLinks>>, TError = void | HTTPValidationError>( - placeId: string, - params: undefined | ListLinksParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData>> & Pick< +export function useListLinks< + TData = Awaited<ReturnType<typeof listLinks>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: undefined | ListLinksParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listLinks>>, TError, Awaited<ReturnType<typeof listLinks>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListLinks<TData = Awaited<ReturnType<typeof listLinks>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListLinksParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListLinks< + TData = Awaited<ReturnType<typeof listLinks>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListLinksParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listLinks>>, TError, Awaited<ReturnType<typeof listLinks>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListLinks<TData = Awaited<ReturnType<typeof listLinks>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListLinksParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListLinks< + TData = Awaited<ReturnType<typeof listLinks>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListLinksParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 채널 URL 목록 */ -export function useListLinks<TData = Awaited<ReturnType<typeof listLinks>>, TError = void | HTTPValidationError>( - placeId: string, - params?: ListLinksParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListLinks< + TData = Awaited<ReturnType<typeof listLinks>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: ListLinksParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLinks>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListLinksQueryOptions(placeId, params, options); - const queryOptions = getListLinksQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 등록만으로는 크롤링 대상이 되지 않는다 — 확정(confirm)이 따로 필요하다. * @summary 채널 URL 등록 */ export const createLink = ( - placeId: string, - reqCreateLink: ReqCreateLink, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqCreateLink: ReqCreateLink, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLink>( - {url: `/v1/place/${placeId}/link`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateLink, signal + return customFetch<ResLink>( + { + url: `/v1/place/${placeId}/link`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateLink, + signal, }, - options); - } - + options, + ); +}; +export const getCreateLinkMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createLink>>, + TError, + { placeId: string; data: ReqCreateLink }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof createLink>>, + TError, + { placeId: string; data: ReqCreateLink }, + TContext +> => { + const mutationKey = ["createLink"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getCreateLinkMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createLink>>, TError,{placeId: string;data: ReqCreateLink}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof createLink>>, TError,{placeId: string;data: ReqCreateLink}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof createLink>>, + { placeId: string; data: ReqCreateLink } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['createLink']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return createLink(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type CreateLinkMutationResult = NonNullable< + Awaited<ReturnType<typeof createLink>> +>; +export type CreateLinkMutationBody = ReqCreateLink; +export type CreateLinkMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof createLink>>, {placeId: string;data: ReqCreateLink}> = (props) => { - const {placeId,data} = props ?? {}; - - return createLink(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateLinkMutationResult = NonNullable<Awaited<ReturnType<typeof createLink>>> - export type CreateLinkMutationBody = ReqCreateLink - export type CreateLinkMutationError = void | HTTPValidationError - - /** +/** * @summary 채널 URL 등록 */ -export const useCreateLink = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createLink>>, TError,{placeId: string;data: ReqCreateLink}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof createLink>>, - TError, - {placeId: string;data: ReqCreateLink}, - TContext - > => { +export const useCreateLink = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof createLink>>, + TError, + { placeId: string; data: ReqCreateLink }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof createLink>>, + TError, + { placeId: string; data: ReqCreateLink }, + TContext +> => { + const mutationOptions = getCreateLinkMutationOptions(options); - const mutationOptions = getCreateLinkMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * ★ 확정된 URL 만 크롤링 대상이 된다. 사업장 검증이 끝나야 확정할 수 있다. * @summary 채널 URL 확정 */ export const confirmLink = ( - placeId: string, - linkId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + linkId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResLink>( - {url: `/v1/place/${placeId}/link/${linkId}/confirm`, method: 'POST', signal + return customFetch<ResLink>( + { + url: `/v1/place/${placeId}/link/${linkId}/confirm`, + method: "POST", + signal, }, - options); - } - + options, + ); +}; +export const getConfirmLinkMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof confirmLink>>, + TError, + { placeId: string; linkId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof confirmLink>>, + TError, + { placeId: string; linkId: string }, + TContext +> => { + const mutationKey = ["confirmLink"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getConfirmLinkMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof confirmLink>>, TError,{placeId: string;linkId: string}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof confirmLink>>, TError,{placeId: string;linkId: string}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof confirmLink>>, + { placeId: string; linkId: string } + > = (props) => { + const { placeId, linkId } = props ?? {}; -const mutationKey = ['confirmLink']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return confirmLink(placeId, linkId, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type ConfirmLinkMutationResult = NonNullable< + Awaited<ReturnType<typeof confirmLink>> +>; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof confirmLink>>, {placeId: string;linkId: string}> = (props) => { - const {placeId,linkId} = props ?? {}; +export type ConfirmLinkMutationError = void | HTTPValidationError; - return confirmLink(placeId,linkId,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type ConfirmLinkMutationResult = NonNullable<Awaited<ReturnType<typeof confirmLink>>> - - export type ConfirmLinkMutationError = void | HTTPValidationError - - /** +/** * @summary 채널 URL 확정 */ -export const useConfirmLink = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof confirmLink>>, TError,{placeId: string;linkId: string}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof confirmLink>>, - TError, - {placeId: string;linkId: string}, - TContext - > => { +export const useConfirmLink = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof confirmLink>>, + TError, + { placeId: string; linkId: string }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof confirmLink>>, + TError, + { placeId: string; linkId: string }, + TContext +> => { + const mutationOptions = getConfirmLinkMutationOptions(options); - const mutationOptions = getConfirmLinkMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 수집 파이프라인을 큐에 넣고 즉시 job_id 를 돌려준다. 한 건에 몇 분 걸리므로 GET /v1/job/{job_id} 로 진행 상태를 폴링한다. ★ 동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다. * @summary 수집 시작(비동기) */ export const startCollect = ( - placeId: string, - reqStartCollect: ReqStartCollect, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqStartCollect: ReqStartCollect, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResStartCollect>( - {url: `/v1/place/${placeId}/collect`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqStartCollect, signal + return customFetch<ResStartCollect>( + { + url: `/v1/place/${placeId}/collect`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqStartCollect, + signal, }, - options); - } - + options, + ); +}; +export const getStartCollectMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startCollect>>, + TError, + { placeId: string; data: ReqStartCollect }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof startCollect>>, + TError, + { placeId: string; data: ReqStartCollect }, + TContext +> => { + const mutationKey = ["startCollect"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getStartCollectMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startCollect>>, TError,{placeId: string;data: ReqStartCollect}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof startCollect>>, TError,{placeId: string;data: ReqStartCollect}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof startCollect>>, + { placeId: string; data: ReqStartCollect } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['startCollect']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return startCollect(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type StartCollectMutationResult = NonNullable< + Awaited<ReturnType<typeof startCollect>> +>; +export type StartCollectMutationBody = ReqStartCollect; +export type StartCollectMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof startCollect>>, {placeId: string;data: ReqStartCollect}> = (props) => { - const {placeId,data} = props ?? {}; - - return startCollect(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type StartCollectMutationResult = NonNullable<Awaited<ReturnType<typeof startCollect>>> - export type StartCollectMutationBody = ReqStartCollect - export type StartCollectMutationError = void | HTTPValidationError - - /** +/** * @summary 수집 시작(비동기) */ -export const useStartCollect = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startCollect>>, TError,{placeId: string;data: ReqStartCollect}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof startCollect>>, - TError, - {placeId: string;data: ReqStartCollect}, - TContext - > => { +export const useStartCollect = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startCollect>>, + TError, + { placeId: string; data: ReqStartCollect }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof startCollect>>, + TError, + { placeId: string; data: ReqStartCollect }, + TContext +> => { + const mutationOptions = getStartCollectMutationOptions(options); - const mutationOptions = getStartCollectMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * Gemini Vision 으로 사진 분류 라벨과 alt 를 생성한다. 수집이 사진을 저장하면 자동으로 걸리며, 사장님이 직접 올린 사진이나 재분석(force)에 이 엔드포인트를 쓴다. ★ 신뢰도가 낮은 결과는 자동 반영되지 않고 사람 확인 큐에 남는다. * @summary 사진 분석 시작(비동기) */ export const startVision = ( - placeId: string, - reqStartVision: ReqStartVision, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqStartVision: ReqStartVision, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResStartVision>( - {url: `/v1/place/${placeId}/vision`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqStartVision, signal + return customFetch<ResStartVision>( + { + url: `/v1/place/${placeId}/vision`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqStartVision, + signal, }, - options); - } - + options, + ); +}; +export const getStartVisionMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startVision>>, + TError, + { placeId: string; data: ReqStartVision }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof startVision>>, + TError, + { placeId: string; data: ReqStartVision }, + TContext +> => { + const mutationKey = ["startVision"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getStartVisionMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startVision>>, TError,{placeId: string;data: ReqStartVision}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof startVision>>, TError,{placeId: string;data: ReqStartVision}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof startVision>>, + { placeId: string; data: ReqStartVision } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['startVision']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return startVision(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type StartVisionMutationResult = NonNullable< + Awaited<ReturnType<typeof startVision>> +>; +export type StartVisionMutationBody = ReqStartVision; +export type StartVisionMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof startVision>>, {placeId: string;data: ReqStartVision}> = (props) => { - const {placeId,data} = props ?? {}; - - return startVision(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type StartVisionMutationResult = NonNullable<Awaited<ReturnType<typeof startVision>>> - export type StartVisionMutationBody = ReqStartVision - export type StartVisionMutationError = void | HTTPValidationError - - /** +/** * @summary 사진 분석 시작(비동기) */ -export const useStartVision = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startVision>>, TError,{placeId: string;data: ReqStartVision}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof startVision>>, - TError, - {placeId: string;data: ReqStartVision}, - TContext - > => { +export const useStartVision = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startVision>>, + TError, + { placeId: string; data: ReqStartVision }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof startVision>>, + TError, + { placeId: string; data: ReqStartVision }, + TContext +> => { + const mutationOptions = getStartVisionMutationOptions(options); - const mutationOptions = getStartVisionMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 외부 장소 DB(카카오 키가 있으면 카카오, 없으면 네이버)에서 후보를 찾아 그대로 돌려준다. ★ 서버가 자동 확정하지 않는다 — UI 가 후보를 보여주고 사람이 고른 뒤 POST /verify 로 확정한다. auto_selectable=true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. * @summary 동일 업소 후보 조회 */ export const verifyCandidates = ( - placeId: string, - params?: VerifyCandidatesParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params?: VerifyCandidatesParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResVerifyCandidates>( - {url: `/v1/place/${placeId}/verify/candidates`, method: 'GET', - params, signal + return customFetch<ResVerifyCandidates>( + { + url: `/v1/place/${placeId}/verify/candidates`, + method: "GET", + params, + signal, }, - options); - } - + options, + ); +}; - - -export const getVerifyCandidatesQueryKey = (placeId?: string, - params?: VerifyCandidatesParams,) => { - return [ - `/v1/place/${placeId}/verify/candidates`, ...(params ? [params]: []) - ] as const; - } - - -export const getVerifyCandidatesQueryOptions = <TData = Awaited<ReturnType<typeof verifyCandidates>>, TError = void | HTTPValidationError>(placeId: string, - params?: VerifyCandidatesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getVerifyCandidatesQueryKey = ( + placeId?: string, + params?: VerifyCandidatesParams, ) => { + return [ + `/v1/place/${placeId}/verify/candidates`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getVerifyCandidatesQueryOptions = < + TData = Awaited<ReturnType<typeof verifyCandidates>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: VerifyCandidatesParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getVerifyCandidatesQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getVerifyCandidatesQueryKey(placeId, params); - + const queryFn: QueryFunction< + Awaited<ReturnType<typeof verifyCandidates>> + > = ({ signal }) => verifyCandidates(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof verifyCandidates>>> = ({ signal }) => verifyCandidates(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - +export type VerifyCandidatesQueryResult = NonNullable< + Awaited<ReturnType<typeof verifyCandidates>> +>; +export type VerifyCandidatesQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type VerifyCandidatesQueryResult = NonNullable<Awaited<ReturnType<typeof verifyCandidates>>> -export type VerifyCandidatesQueryError = void | HTTPValidationError - - -export function useVerifyCandidates<TData = Awaited<ReturnType<typeof verifyCandidates>>, TError = void | HTTPValidationError>( - placeId: string, - params: undefined | VerifyCandidatesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData>> & Pick< +export function useVerifyCandidates< + TData = Awaited<ReturnType<typeof verifyCandidates>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: undefined | VerifyCandidatesParams, + options: { + query: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof verifyCandidates>>, TError, Awaited<ReturnType<typeof verifyCandidates>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useVerifyCandidates<TData = Awaited<ReturnType<typeof verifyCandidates>>, TError = void | HTTPValidationError>( - placeId: string, - params?: VerifyCandidatesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useVerifyCandidates< + TData = Awaited<ReturnType<typeof verifyCandidates>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: VerifyCandidatesParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof verifyCandidates>>, TError, Awaited<ReturnType<typeof verifyCandidates>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useVerifyCandidates<TData = Awaited<ReturnType<typeof verifyCandidates>>, TError = void | HTTPValidationError>( - placeId: string, - params?: VerifyCandidatesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useVerifyCandidates< + TData = Awaited<ReturnType<typeof verifyCandidates>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: VerifyCandidatesParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 동일 업소 후보 조회 */ -export function useVerifyCandidates<TData = Awaited<ReturnType<typeof verifyCandidates>>, TError = void | HTTPValidationError>( - placeId: string, - params?: VerifyCandidatesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof verifyCandidates>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useVerifyCandidates< + TData = Awaited<ReturnType<typeof verifyCandidates>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params?: VerifyCandidatesParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited<ReturnType<typeof verifyCandidates>>, + TError, + TData + > + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getVerifyCandidatesQueryOptions( + placeId, + params, + options, + ); - const queryOptions = getVerifyCandidatesQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 확인된 fact 만 근거로 소개문과 FAQ 를 작성한다. ★ 근거 없는 수치·시설 언급은 코드로 검증해 반려한다(LLM 은 사실을 만들지 않는다). 생성물도 미검증 상태로 들어가 사람이 승인해야 사이트에 나간다. * @summary 소개문·FAQ 생성(비동기) */ export const startCopy = ( - placeId: string, - reqStartCopy: ReqStartCopy, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqStartCopy: ReqStartCopy, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResStartCopy>( - {url: `/v1/place/${placeId}/copy`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqStartCopy, signal + return customFetch<ResStartCopy>( + { + url: `/v1/place/${placeId}/copy`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqStartCopy, + signal, }, - options); - } - + options, + ); +}; +export const getStartCopyMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startCopy>>, + TError, + { placeId: string; data: ReqStartCopy }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof startCopy>>, + TError, + { placeId: string; data: ReqStartCopy }, + TContext +> => { + const mutationKey = ["startCopy"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getStartCopyMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startCopy>>, TError,{placeId: string;data: ReqStartCopy}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof startCopy>>, TError,{placeId: string;data: ReqStartCopy}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof startCopy>>, + { placeId: string; data: ReqStartCopy } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['startCopy']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return startCopy(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type StartCopyMutationResult = NonNullable< + Awaited<ReturnType<typeof startCopy>> +>; +export type StartCopyMutationBody = ReqStartCopy; +export type StartCopyMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof startCopy>>, {placeId: string;data: ReqStartCopy}> = (props) => { - const {placeId,data} = props ?? {}; - - return startCopy(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type StartCopyMutationResult = NonNullable<Awaited<ReturnType<typeof startCopy>>> - export type StartCopyMutationBody = ReqStartCopy - export type StartCopyMutationError = void | HTTPValidationError - - /** +/** * @summary 소개문·FAQ 생성(비동기) */ -export const useStartCopy = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startCopy>>, TError,{placeId: string;data: ReqStartCopy}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof startCopy>>, - TError, - {placeId: string;data: ReqStartCopy}, - TContext - > => { +export const useStartCopy = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startCopy>>, + TError, + { placeId: string; data: ReqStartCopy }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof startCopy>>, + TError, + { placeId: string; data: ReqStartCopy }, + TContext +> => { + const mutationOptions = getStartCopyMutationOptions(options); - const mutationOptions = getStartCopyMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - \ No newline at end of file + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/showcase/showcase.ts b/solution/frontend/src/api/generated/showcase/showcase.ts index 2ed057a..a8a8f14 100644 --- a/solution/frontend/src/api/generated/showcase/showcase.ts +++ b/solution/frontend/src/api/generated/showcase/showcase.ts @@ -4,9 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useQuery -} from '@tanstack/react-query'; +import { useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -16,113 +14,158 @@ import type { QueryKey, UndefinedInitialDataOptions, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { HTTPValidationError, ListShowcaseParams, - ResShowcase -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResShowcase, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * 실제로 발행된 사이트를 최신순으로 준다. 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — 로그인 없이 나가므로 사업장 식별자·전화번호·상세 주소는 싣지 않는다. 썸네일은 그 사이트의 대표 사진이고, 만들지 못한 사이트는 키가 없다. * @summary 발행 사이트 쇼케이스(공개) */ export const listShowcase = ( - params?: ListShowcaseParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params?: ListShowcaseParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResShowcase>( - {url: `/v1/showcase`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResShowcase>( + { url: `/v1/showcase`, method: "GET", params, signal }, + options, + ); +}; +export const getListShowcaseQueryKey = (params?: ListShowcaseParams) => { + return [`/v1/showcase`, ...(params ? [params] : [])] as const; +}; - -export const getListShowcaseQueryKey = (params?: ListShowcaseParams,) => { - return [ - `/v1/showcase`, ...(params ? [params]: []) - ] as const; - } - - -export const getListShowcaseQueryOptions = <TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListShowcaseQueryOptions = < + TData = Awaited<ReturnType<typeof listShowcase>>, + TError = void | HTTPValidationError, +>( + params?: ListShowcaseParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListShowcaseQueryKey(params); - const queryKey = queryOptions?.queryKey ?? getListShowcaseQueryKey(params); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listShowcase>>> = ({ + signal, + }) => listShowcase(params, requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof listShowcase>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listShowcase>>> = ({ signal }) => listShowcase(params, requestOptions, signal); +export type ListShowcaseQueryResult = NonNullable< + Awaited<ReturnType<typeof listShowcase>> +>; +export type ListShowcaseQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListShowcaseQueryResult = NonNullable<Awaited<ReturnType<typeof listShowcase>>> -export type ListShowcaseQueryError = void | HTTPValidationError - - -export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>( - params: undefined | ListShowcaseParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick< +export function useListShowcase< + TData = Awaited<ReturnType<typeof listShowcase>>, + TError = void | HTTPValidationError, +>( + params: undefined | ListShowcaseParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listShowcase>>, TError, Awaited<ReturnType<typeof listShowcase>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>( - params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListShowcase< + TData = Awaited<ReturnType<typeof listShowcase>>, + TError = void | HTTPValidationError, +>( + params?: ListShowcaseParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listShowcase>>, TError, Awaited<ReturnType<typeof listShowcase>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>( - params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListShowcase< + TData = Awaited<ReturnType<typeof listShowcase>>, + TError = void | HTTPValidationError, +>( + params?: ListShowcaseParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 발행 사이트 쇼케이스(공개) */ -export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>( - params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListShowcase< + TData = Awaited<ReturnType<typeof listShowcase>>, + TError = void | HTTPValidationError, +>( + params?: ListShowcaseParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListShowcaseQueryOptions(params, options); - const queryOptions = getListShowcaseQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - - diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index c44b8ee..79ebd49 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -4,10 +4,7 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, @@ -20,13 +17,14 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from "@tanstack/react-query"; import type { CheckSlugParams, HTTPValidationError, ListMySitesParams, + ReqRollback, ReqSiteSlug, ReqSiteStatus, ReqSiteTemplate, @@ -39,909 +37,1609 @@ import type { ResSiteSlug, ResSiteVersions, ResSlugCheck, - ResStartBuild -} from '.././model'; - -import { customFetch } from '../../mutator/custom-fetch'; + ResStartBuild, +} from ".././model"; +import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; - - /** * 현재 발행 버전과 재빌드 필요 여부(needs_rebuild). 노출값이 바뀐 뒤 다시 빌드하지 않았으면 true — 이 사업장만 재빌드하면 된다. * @summary 사이트 상태 */ export const getSite = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSite>( - {url: `/v1/place/${placeId}/site`, method: 'GET', signal - }, - options); - } - + return customFetch<ResSite>( + { url: `/v1/place/${placeId}/site`, method: "GET", signal }, + options, + ); +}; +export const getGetSiteQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/site`] as const; +}; - -export const getGetSiteQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/site` - ] as const; - } - - -export const getGetSiteQueryOptions = <TData = Awaited<ReturnType<typeof getSite>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetSiteQueryOptions = < + TData = Awaited<ReturnType<typeof getSite>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetSiteQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getGetSiteQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getSite>>> = ({ + signal, + }) => getSite(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getSite>>> = ({ signal }) => getSite(placeId, requestOptions, signal); +export type GetSiteQueryResult = NonNullable< + Awaited<ReturnType<typeof getSite>> +>; +export type GetSiteQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetSiteQueryResult = NonNullable<Awaited<ReturnType<typeof getSite>>> -export type GetSiteQueryError = void | HTTPValidationError - - -export function useGetSite<TData = Awaited<ReturnType<typeof getSite>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData>> & Pick< +export function useGetSite< + TData = Awaited<ReturnType<typeof getSite>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getSite>>, TError, Awaited<ReturnType<typeof getSite>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSite<TData = Awaited<ReturnType<typeof getSite>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSite< + TData = Awaited<ReturnType<typeof getSite>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getSite>>, TError, Awaited<ReturnType<typeof getSite>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSite<TData = Awaited<ReturnType<typeof getSite>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSite< + TData = Awaited<ReturnType<typeof getSite>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 사이트 상태 */ -export function useGetSite<TData = Awaited<ReturnType<typeof getSite>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetSite< + TData = Awaited<ReturnType<typeof getSite>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSite>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetSiteQueryOptions(placeId, options); - const queryOptions = getGetSiteQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 현재 확인된 콘텐츠와 최신 빌드를 SEO·AEO 각각 100점으로 채점하고 개선 항목을 반환한다. 준비도 점수이며 실제 검색 순위나 AI 인용을 보장하지 않는다. * @summary SEO/AEO 준비도 진단 */ export const getSeoAudit = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSeoAudit>( - {url: `/v1/place/${placeId}/site/audit`, method: 'GET', signal - }, - options); - } - + return customFetch<ResSeoAudit>( + { url: `/v1/place/${placeId}/site/audit`, method: "GET", signal }, + options, + ); +}; +export const getGetSeoAuditQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/site/audit`] as const; +}; - -export const getGetSeoAuditQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/site/audit` - ] as const; - } - - -export const getGetSeoAuditQueryOptions = <TData = Awaited<ReturnType<typeof getSeoAudit>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getGetSeoAuditQueryOptions = < + TData = Awaited<ReturnType<typeof getSeoAudit>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getGetSeoAuditQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getGetSeoAuditQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof getSeoAudit>>> = ({ + signal, + }) => getSeoAudit(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof getSeoAudit>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof getSeoAudit>>> = ({ signal }) => getSeoAudit(placeId, requestOptions, signal); +export type GetSeoAuditQueryResult = NonNullable< + Awaited<ReturnType<typeof getSeoAudit>> +>; +export type GetSeoAuditQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type GetSeoAuditQueryResult = NonNullable<Awaited<ReturnType<typeof getSeoAudit>>> -export type GetSeoAuditQueryError = void | HTTPValidationError - - -export function useGetSeoAudit<TData = Awaited<ReturnType<typeof getSeoAudit>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData>> & Pick< +export function useGetSeoAudit< + TData = Awaited<ReturnType<typeof getSeoAudit>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof getSeoAudit>>, TError, Awaited<ReturnType<typeof getSeoAudit>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSeoAudit<TData = Awaited<ReturnType<typeof getSeoAudit>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSeoAudit< + TData = Awaited<ReturnType<typeof getSeoAudit>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof getSeoAudit>>, TError, Awaited<ReturnType<typeof getSeoAudit>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useGetSeoAudit<TData = Awaited<ReturnType<typeof getSeoAudit>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useGetSeoAudit< + TData = Awaited<ReturnType<typeof getSeoAudit>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary SEO/AEO 준비도 진단 */ -export function useGetSeoAudit<TData = Awaited<ReturnType<typeof getSeoAudit>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useGetSeoAudit< + TData = Awaited<ReturnType<typeof getSeoAudit>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof getSeoAudit>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getGetSeoAuditQueryOptions(placeId, options); - const queryOptions = getGetSeoAuditQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 쓰고 싶은 주소를 미리 본다. 소문자 영문·숫자·하이픈 3~50자만 되고, 예약어와 남이 쓰는 주소는 막힌다. ★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고른다. 못 쓰면 reason(INVALID_LENGTH·INVALID_FORMAT·RESERVED·TAKEN)과 대안 하나(suggestion)를 준다. 지금 자기가 쓰는 주소면 available=true 다. * @summary 사이트 주소 사용 가능 확인 */ export const checkSlug = ( - placeId: string, - params: CheckSlugParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + params: CheckSlugParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSlugCheck>( - {url: `/v1/place/${placeId}/site/slug/check`, method: 'GET', - params, signal + return customFetch<ResSlugCheck>( + { + url: `/v1/place/${placeId}/site/slug/check`, + method: "GET", + params, + signal, }, - options); - } - + options, + ); +}; - - -export const getCheckSlugQueryKey = (placeId?: string, - params?: CheckSlugParams,) => { - return [ - `/v1/place/${placeId}/site/slug/check`, ...(params ? [params]: []) - ] as const; - } - - -export const getCheckSlugQueryOptions = <TData = Awaited<ReturnType<typeof checkSlug>>, TError = void | HTTPValidationError>(placeId: string, - params: CheckSlugParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getCheckSlugQueryKey = ( + placeId?: string, + params?: CheckSlugParams, ) => { + return [ + `/v1/place/${placeId}/site/slug/check`, + ...(params ? [params] : []), + ] as const; +}; -const {query: queryOptions, request: requestOptions} = options ?? {}; +export const getCheckSlugQueryOptions = < + TData = Awaited<ReturnType<typeof checkSlug>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: CheckSlugParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getCheckSlugQueryKey(placeId,params); + const queryKey = + queryOptions?.queryKey ?? getCheckSlugQueryKey(placeId, params); - + const queryFn: QueryFunction<Awaited<ReturnType<typeof checkSlug>>> = ({ + signal, + }) => checkSlug(placeId, params, requestOptions, signal); - const queryFn: QueryFunction<Awaited<ReturnType<typeof checkSlug>>> = ({ signal }) => checkSlug(placeId,params, requestOptions, signal); + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - +export type CheckSlugQueryResult = NonNullable< + Awaited<ReturnType<typeof checkSlug>> +>; +export type CheckSlugQueryError = void | HTTPValidationError; - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type CheckSlugQueryResult = NonNullable<Awaited<ReturnType<typeof checkSlug>>> -export type CheckSlugQueryError = void | HTTPValidationError - - -export function useCheckSlug<TData = Awaited<ReturnType<typeof checkSlug>>, TError = void | HTTPValidationError>( - placeId: string, - params: CheckSlugParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData>> & Pick< +export function useCheckSlug< + TData = Awaited<ReturnType<typeof checkSlug>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: CheckSlugParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof checkSlug>>, TError, Awaited<ReturnType<typeof checkSlug>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useCheckSlug<TData = Awaited<ReturnType<typeof checkSlug>>, TError = void | HTTPValidationError>( - placeId: string, - params: CheckSlugParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useCheckSlug< + TData = Awaited<ReturnType<typeof checkSlug>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: CheckSlugParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof checkSlug>>, TError, Awaited<ReturnType<typeof checkSlug>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useCheckSlug<TData = Awaited<ReturnType<typeof checkSlug>>, TError = void | HTTPValidationError>( - placeId: string, - params: CheckSlugParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useCheckSlug< + TData = Awaited<ReturnType<typeof checkSlug>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: CheckSlugParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 사이트 주소 사용 가능 확인 */ -export function useCheckSlug<TData = Awaited<ReturnType<typeof checkSlug>>, TError = void | HTTPValidationError>( - placeId: string, - params: CheckSlugParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useCheckSlug< + TData = Awaited<ReturnType<typeof checkSlug>>, + TError = void | HTTPValidationError, +>( + placeId: string, + params: CheckSlugParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof checkSlug>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getCheckSlugQueryOptions(placeId, params, options); - const queryOptions = getCheckSlugQueryOptions(placeId,params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * 주소를 sites.domain 에 저장한다(사이트 행이 없으면 만든다). 검증은 check 와 같은 규칙으로 서버가 다시 한다 — 클라이언트 검증을 믿지 않는다. ★ 이미 발행된 사이트의 주소는 바꿀 수 없다(SITE_SLUG_LOCKED) — 색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 된다. * @summary 사이트 주소 확정 */ export const setSlug = ( - placeId: string, - reqSiteSlug: ReqSiteSlug, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqSiteSlug: ReqSiteSlug, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSiteSlug>( - {url: `/v1/place/${placeId}/site/slug`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSiteSlug, signal + return customFetch<ResSiteSlug>( + { + url: `/v1/place/${placeId}/site/slug`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqSiteSlug, + signal, }, - options); - } - + options, + ); +}; +export const getSetSlugMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setSlug>>, + TError, + { placeId: string; data: ReqSiteSlug }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof setSlug>>, + TError, + { placeId: string; data: ReqSiteSlug }, + TContext +> => { + const mutationKey = ["setSlug"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getSetSlugMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setSlug>>, TError,{placeId: string;data: ReqSiteSlug}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof setSlug>>, TError,{placeId: string;data: ReqSiteSlug}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof setSlug>>, + { placeId: string; data: ReqSiteSlug } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['setSlug']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return setSlug(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type SetSlugMutationResult = NonNullable< + Awaited<ReturnType<typeof setSlug>> +>; +export type SetSlugMutationBody = ReqSiteSlug; +export type SetSlugMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof setSlug>>, {placeId: string;data: ReqSiteSlug}> = (props) => { - const {placeId,data} = props ?? {}; - - return setSlug(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type SetSlugMutationResult = NonNullable<Awaited<ReturnType<typeof setSlug>>> - export type SetSlugMutationBody = ReqSiteSlug - export type SetSlugMutationError = void | HTTPValidationError - - /** +/** * @summary 사이트 주소 확정 */ -export const useSetSlug = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setSlug>>, TError,{placeId: string;data: ReqSiteSlug}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof setSlug>>, - TError, - {placeId: string;data: ReqSiteSlug}, - TContext - > => { +export const useSetSlug = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setSlug>>, + TError, + { placeId: string; data: ReqSiteSlug }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof setSlug>>, + TError, + { placeId: string; data: ReqSiteSlug }, + TContext +> => { + const mutationOptions = getSetSlugMutationOptions(options); - const mutationOptions = getSetSlugMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. ★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * @summary 템플릿(디자인) 선택 저장 */ export const setTemplate = ( - placeId: string, - reqSiteTemplate: ReqSiteTemplate, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqSiteTemplate: ReqSiteTemplate, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSite>( - {url: `/v1/place/${placeId}/site/template`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSiteTemplate, signal + return customFetch<ResSite>( + { + url: `/v1/place/${placeId}/site/template`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqSiteTemplate, + signal, }, - options); - } - + options, + ); +}; +export const getSetTemplateMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setTemplate>>, + TError, + { placeId: string; data: ReqSiteTemplate }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof setTemplate>>, + TError, + { placeId: string; data: ReqSiteTemplate }, + TContext +> => { + const mutationKey = ["setTemplate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getSetTemplateMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setTemplate>>, TError,{placeId: string;data: ReqSiteTemplate}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof setTemplate>>, TError,{placeId: string;data: ReqSiteTemplate}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof setTemplate>>, + { placeId: string; data: ReqSiteTemplate } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['setTemplate']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return setTemplate(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type SetTemplateMutationResult = NonNullable< + Awaited<ReturnType<typeof setTemplate>> +>; +export type SetTemplateMutationBody = ReqSiteTemplate; +export type SetTemplateMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof setTemplate>>, {placeId: string;data: ReqSiteTemplate}> = (props) => { - const {placeId,data} = props ?? {}; - - return setTemplate(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type SetTemplateMutationResult = NonNullable<Awaited<ReturnType<typeof setTemplate>>> - export type SetTemplateMutationBody = ReqSiteTemplate - export type SetTemplateMutationError = void | HTTPValidationError - - /** +/** * @summary 템플릿(디자인) 선택 저장 */ -export const useSetTemplate = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setTemplate>>, TError,{placeId: string;data: ReqSiteTemplate}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof setTemplate>>, - TError, - {placeId: string;data: ReqSiteTemplate}, - TContext - > => { +export const useSetTemplate = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setTemplate>>, + TError, + { placeId: string; data: ReqSiteTemplate }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof setTemplate>>, + TError, + { placeId: string; data: ReqSiteTemplate }, + TContext +> => { + const mutationOptions = getSetTemplateMutationOptions(options); - const mutationOptions = getSetTemplateMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * @summary 디자인(색·서체·섹션) 저장 */ export const setTheme = ( - placeId: string, - reqSiteTheme: ReqSiteTheme, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqSiteTheme: ReqSiteTheme, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSite>( - {url: `/v1/place/${placeId}/site/theme`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSiteTheme, signal + return customFetch<ResSite>( + { + url: `/v1/place/${placeId}/site/theme`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqSiteTheme, + signal, }, - options); - } - + options, + ); +}; +export const getSetThemeMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setTheme>>, + TError, + { placeId: string; data: ReqSiteTheme }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof setTheme>>, + TError, + { placeId: string; data: ReqSiteTheme }, + TContext +> => { + const mutationKey = ["setTheme"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getSetThemeMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setTheme>>, TError,{placeId: string;data: ReqSiteTheme}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof setTheme>>, TError,{placeId: string;data: ReqSiteTheme}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof setTheme>>, + { placeId: string; data: ReqSiteTheme } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['setTheme']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return setTheme(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type SetThemeMutationResult = NonNullable< + Awaited<ReturnType<typeof setTheme>> +>; +export type SetThemeMutationBody = ReqSiteTheme; +export type SetThemeMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof setTheme>>, {placeId: string;data: ReqSiteTheme}> = (props) => { - const {placeId,data} = props ?? {}; - - return setTheme(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type SetThemeMutationResult = NonNullable<Awaited<ReturnType<typeof setTheme>>> - export type SetThemeMutationBody = ReqSiteTheme - export type SetThemeMutationError = void | HTTPValidationError - - /** +/** * @summary 디자인(색·서체·섹션) 저장 */ -export const useSetTheme = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setTheme>>, TError,{placeId: string;data: ReqSiteTheme}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof setTheme>>, - TError, - {placeId: string;data: ReqSiteTheme}, - TContext - > => { +export const useSetTheme = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof setTheme>>, + TError, + { placeId: string; data: ReqSiteTheme }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof setTheme>>, + TError, + { placeId: string; data: ReqSiteTheme }, + TContext +> => { + const mutationOptions = getSetThemeMutationOptions(options); - const mutationOptions = getSetThemeMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 스냅샷을 박제해 HTML + JSON-LD 를 만들고 발행 검수 게이트를 돌린다. publish=true 면 통과 시 바로 발행한다. ★ 게이트를 우회하는 옵션은 없다. * @summary 정적 빌드(비동기) */ export const startBuild = ( - placeId: string, - reqStartBuild: ReqStartBuild, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqStartBuild: ReqStartBuild, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResStartBuild>( - {url: `/v1/place/${placeId}/site/build`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqStartBuild, signal + return customFetch<ResStartBuild>( + { + url: `/v1/place/${placeId}/site/build`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqStartBuild, + signal, }, - options); - } - + options, + ); +}; +export const getStartBuildMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startBuild>>, + TError, + { placeId: string; data: ReqStartBuild }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof startBuild>>, + TError, + { placeId: string; data: ReqStartBuild }, + TContext +> => { + const mutationKey = ["startBuild"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getStartBuildMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startBuild>>, TError,{placeId: string;data: ReqStartBuild}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof startBuild>>, TError,{placeId: string;data: ReqStartBuild}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof startBuild>>, + { placeId: string; data: ReqStartBuild } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['startBuild']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return startBuild(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type StartBuildMutationResult = NonNullable< + Awaited<ReturnType<typeof startBuild>> +>; +export type StartBuildMutationBody = ReqStartBuild; +export type StartBuildMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof startBuild>>, {placeId: string;data: ReqStartBuild}> = (props) => { - const {placeId,data} = props ?? {}; - - return startBuild(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type StartBuildMutationResult = NonNullable<Awaited<ReturnType<typeof startBuild>>> - export type StartBuildMutationBody = ReqStartBuild - export type StartBuildMutationError = void | HTTPValidationError - - /** +/** * @summary 정적 빌드(비동기) */ -export const useStartBuild = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startBuild>>, TError,{placeId: string;data: ReqStartBuild}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof startBuild>>, - TError, - {placeId: string;data: ReqStartBuild}, - TContext - > => { +export const useStartBuild = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof startBuild>>, + TError, + { placeId: string; data: ReqStartBuild }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof startBuild>>, + TError, + { placeId: string; data: ReqStartBuild }, + TContext +> => { + const mutationOptions = getStartBuildMutationOptions(options); - const mutationOptions = getStartBuildMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** - * @summary 빌드 버전 목록 - */ -export const listVersions = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal -) => { - - - return customFetch<ResSiteVersions>( - {url: `/v1/place/${placeId}/site/version/list`, method: 'GET', signal - }, - options); - } - - - - -export const getListVersionsQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/site/version/list` - ] as const; - } - - -export const getListVersionsQueryOptions = <TData = Awaited<ReturnType<typeof listVersions>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListVersionsQueryKey(placeId); - - - - const queryFn: QueryFunction<Awaited<ReturnType<typeof listVersions>>> = ({ signal }) => listVersions(placeId, requestOptions, signal); - - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListVersionsQueryResult = NonNullable<Awaited<ReturnType<typeof listVersions>>> -export type ListVersionsQueryError = void | HTTPValidationError - - -export function useListVersions<TData = Awaited<ReturnType<typeof listVersions>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited<ReturnType<typeof listVersions>>, - TError, - Awaited<ReturnType<typeof listVersions>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListVersions<TData = Awaited<ReturnType<typeof listVersions>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited<ReturnType<typeof listVersions>>, - TError, - Awaited<ReturnType<typeof listVersions>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListVersions<TData = Awaited<ReturnType<typeof listVersions>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + return useMutation(mutationOptions, queryClient); +}; /** - * @summary 빌드 버전 목록 + * 발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. 미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. ★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다. + * @summary 에디터 미리보기 payload — 발행본과 같은 것 + */ +export const sitePreview = ( + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<unknown>( + { url: `/v1/place/${placeId}/site/preview`, method: "GET", signal }, + options, + ); +}; + +export const getSitePreviewQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/site/preview`] as const; +}; + +export const getSitePreviewQueryOptions = < + TData = Awaited<ReturnType<typeof sitePreview>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof sitePreview>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSitePreviewQueryKey(placeId); + + const queryFn: QueryFunction<Awaited<ReturnType<typeof sitePreview>>> = ({ + signal, + }) => sitePreview(placeId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof sitePreview>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; + +export type SitePreviewQueryResult = NonNullable< + Awaited<ReturnType<typeof sitePreview>> +>; +export type SitePreviewQueryError = void | HTTPValidationError; + +export function useSitePreview< + TData = Awaited<ReturnType<typeof sitePreview>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof sitePreview>>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited<ReturnType<typeof sitePreview>>, + TError, + Awaited<ReturnType<typeof sitePreview>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useSitePreview< + TData = Awaited<ReturnType<typeof sitePreview>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof sitePreview>>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited<ReturnType<typeof sitePreview>>, + TError, + Awaited<ReturnType<typeof sitePreview>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useSitePreview< + TData = Awaited<ReturnType<typeof sitePreview>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof sitePreview>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +/** + * @summary 에디터 미리보기 payload — 발행본과 같은 것 */ -export function useListVersions<TData = Awaited<ReturnType<typeof listVersions>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useSitePreview< + TData = Awaited<ReturnType<typeof sitePreview>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof sitePreview>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getSitePreviewQueryOptions(placeId, options); - const queryOptions = getListVersionsQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } +/** + * target_version 을 다시 공개 주소로 내보낸다. 재수집·재생성을 하지 않는다 — 그 버전이 발행될 때 박제된 snapshot 을 그대로 다시 굽는다. 렌더러가 그 사이 바뀌었을 수 있어 게이트는 다시 통과해야 한다. 실패하면 지금 공개된 버전은 그대로다. + * @summary 예전 버전으로 되돌리기(비동기) + */ +export const rollbackVersion = ( + placeId: string, + reqRollback: ReqRollback, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResStartBuild>( + { + url: `/v1/place/${placeId}/site/version/rollback`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqRollback, + signal, + }, + options, + ); +}; +export const getRollbackVersionMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof rollbackVersion>>, + TError, + { placeId: string; data: ReqRollback }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof rollbackVersion>>, + TError, + { placeId: string; data: ReqRollback }, + TContext +> => { + const mutationKey = ["rollbackVersion"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof rollbackVersion>>, + { placeId: string; data: ReqRollback } + > = (props) => { + const { placeId, data } = props ?? {}; + + return rollbackVersion(placeId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RollbackVersionMutationResult = NonNullable< + Awaited<ReturnType<typeof rollbackVersion>> +>; +export type RollbackVersionMutationBody = ReqRollback; +export type RollbackVersionMutationError = void | HTTPValidationError; + +/** + * @summary 예전 버전으로 되돌리기(비동기) + */ +export const useRollbackVersion = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof rollbackVersion>>, + TError, + { placeId: string; data: ReqRollback }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof rollbackVersion>>, + TError, + { placeId: string; data: ReqRollback }, + TContext +> => { + const mutationOptions = getRollbackVersionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary 빌드 버전 목록 + */ +export const listVersions = ( + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, +) => { + return customFetch<ResSiteVersions>( + { url: `/v1/place/${placeId}/site/version/list`, method: "GET", signal }, + options, + ); +}; + +export const getListVersionsQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/site/version/list`] as const; +}; + +export const getListVersionsQueryOptions = < + TData = Awaited<ReturnType<typeof listVersions>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListVersionsQueryKey(placeId); + + const queryFn: QueryFunction<Awaited<ReturnType<typeof listVersions>>> = ({ + signal, + }) => listVersions(placeId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions< + Awaited<ReturnType<typeof listVersions>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; + +export type ListVersionsQueryResult = NonNullable< + Awaited<ReturnType<typeof listVersions>> +>; +export type ListVersionsQueryError = void | HTTPValidationError; + +export function useListVersions< + TData = Awaited<ReturnType<typeof listVersions>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited<ReturnType<typeof listVersions>>, + TError, + Awaited<ReturnType<typeof listVersions>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListVersions< + TData = Awaited<ReturnType<typeof listVersions>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited<ReturnType<typeof listVersions>>, + TError, + Awaited<ReturnType<typeof listVersions>> + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListVersions< + TData = Awaited<ReturnType<typeof listVersions>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +/** + * @summary 빌드 버전 목록 + */ + +export function useListVersions< + TData = Awaited<ReturnType<typeof listVersions>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listVersions>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListVersionsQueryOptions(placeId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; + + query.queryKey = queryOptions.queryKey; + + return query; +} /** * 발행 시도와 거부 사유. 게이트가 막았으면 무엇이 문제였는지 detail 에 남는다. * @summary 발행 기록 */ export const listLogs = ( - placeId: string, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResPublishLogs>( - {url: `/v1/place/${placeId}/site/log/list`, method: 'GET', signal - }, - options); - } - + return customFetch<ResPublishLogs>( + { url: `/v1/place/${placeId}/site/log/list`, method: "GET", signal }, + options, + ); +}; +export const getListLogsQueryKey = (placeId?: string) => { + return [`/v1/place/${placeId}/site/log/list`] as const; +}; - -export const getListLogsQueryKey = (placeId?: string,) => { - return [ - `/v1/place/${placeId}/site/log/list` - ] as const; - } - - -export const getListLogsQueryOptions = <TData = Awaited<ReturnType<typeof listLogs>>, TError = void | HTTPValidationError>(placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListLogsQueryOptions = < + TData = Awaited<ReturnType<typeof listLogs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListLogsQueryKey(placeId); - const queryKey = queryOptions?.queryKey ?? getListLogsQueryKey(placeId); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listLogs>>> = ({ + signal, + }) => listLogs(placeId, requestOptions, signal); - + return { + queryKey, + queryFn, + enabled: !!placeId, + ...queryOptions, + } as UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> & { + queryKey: DataTag<QueryKey, TData, TError>; + }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listLogs>>> = ({ signal }) => listLogs(placeId, requestOptions, signal); +export type ListLogsQueryResult = NonNullable< + Awaited<ReturnType<typeof listLogs>> +>; +export type ListLogsQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, enabled: !!(placeId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListLogsQueryResult = NonNullable<Awaited<ReturnType<typeof listLogs>>> -export type ListLogsQueryError = void | HTTPValidationError - - -export function useListLogs<TData = Awaited<ReturnType<typeof listLogs>>, TError = void | HTTPValidationError>( - placeId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData>> & Pick< +export function useListLogs< + TData = Awaited<ReturnType<typeof listLogs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listLogs>>, TError, Awaited<ReturnType<typeof listLogs>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListLogs<TData = Awaited<ReturnType<typeof listLogs>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListLogs< + TData = Awaited<ReturnType<typeof listLogs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listLogs>>, TError, Awaited<ReturnType<typeof listLogs>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListLogs<TData = Awaited<ReturnType<typeof listLogs>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListLogs< + TData = Awaited<ReturnType<typeof listLogs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 발행 기록 */ -export function useListLogs<TData = Awaited<ReturnType<typeof listLogs>>, TError = void | HTTPValidationError>( - placeId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListLogs< + TData = Awaited<ReturnType<typeof listLogs>>, + TError = void | HTTPValidationError, +>( + placeId: string, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listLogs>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListLogsQueryOptions(placeId, options); - const queryOptions = getListLogsQueryOptions(placeId,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - /** * ★ 해지는 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들면 AI 검색이 그 자리를 다시 OTA 로 채운다. * @summary 발행 상태 전이(중지·재개·내림) */ export const changeStatus = ( - placeId: string, - reqSiteStatus: ReqSiteStatus, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + placeId: string, + reqSiteStatus: ReqSiteStatus, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResSite>( - {url: `/v1/place/${placeId}/site/status`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqSiteStatus, signal + return customFetch<ResSite>( + { + url: `/v1/place/${placeId}/site/status`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqSiteStatus, + signal, }, - options); - } - + options, + ); +}; +export const getChangeStatusMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof changeStatus>>, + TError, + { placeId: string; data: ReqSiteStatus }, + TContext + >; + request?: SecondParameter<typeof customFetch>; +}): UseMutationOptions< + Awaited<ReturnType<typeof changeStatus>>, + TError, + { placeId: string; data: ReqSiteStatus }, + TContext +> => { + const mutationKey = ["changeStatus"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; -export const getChangeStatusMutationOptions = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeStatus>>, TError,{placeId: string;data: ReqSiteStatus}, TContext>, request?: SecondParameter<typeof customFetch>} -): UseMutationOptions<Awaited<ReturnType<typeof changeStatus>>, TError,{placeId: string;data: ReqSiteStatus}, TContext> => { + const mutationFn: MutationFunction< + Awaited<ReturnType<typeof changeStatus>>, + { placeId: string; data: ReqSiteStatus } + > = (props) => { + const { placeId, data } = props ?? {}; -const mutationKey = ['changeStatus']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return changeStatus(placeId, data, requestOptions); + }; - + return { mutationFn, ...mutationOptions }; +}; +export type ChangeStatusMutationResult = NonNullable< + Awaited<ReturnType<typeof changeStatus>> +>; +export type ChangeStatusMutationBody = ReqSiteStatus; +export type ChangeStatusMutationError = void | HTTPValidationError; - const mutationFn: MutationFunction<Awaited<ReturnType<typeof changeStatus>>, {placeId: string;data: ReqSiteStatus}> = (props) => { - const {placeId,data} = props ?? {}; - - return changeStatus(placeId,data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type ChangeStatusMutationResult = NonNullable<Awaited<ReturnType<typeof changeStatus>>> - export type ChangeStatusMutationBody = ReqSiteStatus - export type ChangeStatusMutationError = void | HTTPValidationError - - /** +/** * @summary 발행 상태 전이(중지·재개·내림) */ -export const useChangeStatus = <TError = void | HTTPValidationError, - TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeStatus>>, TError,{placeId: string;data: ReqSiteStatus}, TContext>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient): UseMutationResult< - Awaited<ReturnType<typeof changeStatus>>, - TError, - {placeId: string;data: ReqSiteStatus}, - TContext - > => { +export const useChangeStatus = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited<ReturnType<typeof changeStatus>>, + TError, + { placeId: string; data: ReqSiteStatus }, + TContext + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited<ReturnType<typeof changeStatus>>, + TError, + { placeId: string; data: ReqSiteStatus }, + TContext +> => { + const mutationOptions = getChangeStatusMutationOptions(options); - const mutationOptions = getChangeStatusMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** + return useMutation(mutationOptions, queryClient); +}; +/** * 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다. * @summary 내 사이트 목록 */ export const listMySites = ( - params?: ListMySitesParams, - options?: SecondParameter<typeof customFetch>,signal?: AbortSignal + params?: ListMySitesParams, + options?: SecondParameter<typeof customFetch>, + signal?: AbortSignal, ) => { - - - return customFetch<ResMySites>( - {url: `/v1/site/list`, method: 'GET', - params, signal - }, - options); - } - + return customFetch<ResMySites>( + { url: `/v1/site/list`, method: "GET", params, signal }, + options, + ); +}; +export const getListMySitesQueryKey = (params?: ListMySitesParams) => { + return [`/v1/site/list`, ...(params ? [params] : [])] as const; +}; - -export const getListMySitesQueryKey = (params?: ListMySitesParams,) => { - return [ - `/v1/site/list`, ...(params ? [params]: []) - ] as const; - } - - -export const getListMySitesQueryOptions = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} +export const getListMySitesQueryOptions = < + TData = Awaited<ReturnType<typeof listMySites>>, + TError = void | HTTPValidationError, +>( + params?: ListMySitesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; -const {query: queryOptions, request: requestOptions} = options ?? {}; + const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params); - const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params); + const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ + signal, + }) => listMySites(params, requestOptions, signal); - + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited<ReturnType<typeof listMySites>>, + TError, + TData + > & { queryKey: DataTag<QueryKey, TData, TError> }; +}; - const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal); +export type ListMySitesQueryResult = NonNullable< + Awaited<ReturnType<typeof listMySites>> +>; +export type ListMySitesQueryError = void | HTTPValidationError; - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> } -} - -export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>> -export type ListMySitesQueryError = void | HTTPValidationError - - -export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>( - params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick< +export function useListMySites< + TData = Awaited<ReturnType<typeof listMySites>>, + TError = void | HTTPValidationError, +>( + params: undefined | ListMySitesParams, + options: { + query: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited<ReturnType<typeof listMySites>>, TError, Awaited<ReturnType<typeof listMySites>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>( - params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick< + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListMySites< + TData = Awaited<ReturnType<typeof listMySites>>, + TError = void | HTTPValidationError, +>( + params?: ListMySitesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited<ReturnType<typeof listMySites>>, TError, Awaited<ReturnType<typeof listMySites>> - > , 'initialData' - >, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } -export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>( - params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } + >, + "initialData" + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; +export function useListMySites< + TData = Awaited<ReturnType<typeof listMySites>>, + TError = void | HTTPValidationError, +>( + params?: ListMySitesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +}; /** * @summary 내 사이트 목록 */ -export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>( - params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>} - , queryClient?: QueryClient - ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { +export function useListMySites< + TData = Awaited<ReturnType<typeof listMySites>>, + TError = void | HTTPValidationError, +>( + params?: ListMySitesParams, + options?: { + query?: Partial< + UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> + >; + request?: SecondParameter<typeof customFetch>; + }, + queryClient?: QueryClient, +): UseQueryResult<TData, TError> & { + queryKey: DataTag<QueryKey, TData, TError>; +} { + const queryOptions = getListMySitesQueryOptions(params, options); - const queryOptions = getListMySitesQueryOptions(params,options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag<QueryKey, TData, TError> }; - const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; - - query.queryKey = queryOptions.queryKey ; + query.queryKey = queryOptions.queryKey; return query; } - - - - diff --git a/solution/frontend/src/api/pollJob.ts b/solution/frontend/src/api/pollJob.ts index e9283da..3ebcfd3 100644 --- a/solution/frontend/src/api/pollJob.ts +++ b/solution/frontend/src/api/pollJob.ts @@ -16,8 +16,16 @@ import type {JobData} from './generated/model'; /** 잡 폴링 간격. 수집·빌드는 분 단위로 도는 작업이라 더 촘촘히 물어봐도 얻는 게 없다. */ export const JOB_POLL_MS = 2000; -/** ★ 폴링 총 제한시간. 워커가 죽어 잡이 RUNNING 에 박제되면 여기서 끊는다 — 무한 폴링 금지. */ -export const JOB_POLL_TIMEOUT_MS = 3 * 60 * 1000; +/** + * ★ 폴링 총 제한시간. 워커가 죽어 잡이 RUNNING 에 박제되면 여기서 끊는다 — 무한 폴링 금지. + * + * ★ 실제 잡 시간보다 넉넉해야 한다. 3분이던 동안 정상적으로 끝난 발행이 '실패' 로 보였다 — + * 실측(2026-09-15, 완료된 잡 전체): BUILD 최대 181s · VISION 최대 213s · SONG 최대 157s. + * `publish=true` 는 노래·일정 생성을 빌드 안에서 **먼저** 돌리므로 더 길어진다. + * 워커 쪽 상한은 JOB_DEADLINE_SEC(기본 900s)이니 화면이 그보다 먼저 접으면 + * 아직 도는 잡을 죽은 것으로 보고한다. + */ +export const JOB_POLL_TIMEOUT_MS = 10 * 60 * 1000; /** ★ 연속 실패 허용치. 잠깐 끊긴 건 넘기고, 계속 실패하면 제한시간을 다 기다리지 않고 접는다. */ export const JOB_MAX_POLL_ERRORS = 3; diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index ee1b058..5335279 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -141,51 +141,12 @@ export function CanvasView() { }} /> - <footer - className="border-t border-black/10 p-6 text-center text-xs" - style={{backgroundColor: template.colors.bg, color: template.colors.secondary}} - > - <div className="mx-auto max-w-4xl space-y-2"> - <p className="font-bold" style={{color: template.colors.text}}> - {storeName} - </p> - <p className="text-[11px]"> - {infoFields.find((f) => f.id === 'address')?.value || location} - </p> - <p className="pt-2 text-[10px] opacity-60"> - © {new Date().getFullYear()} {storeName}. Powered by Web4Ai. - </p> - </div> - </footer> - - {viewport === 'mobile' && ( - <nav - aria-label="모바일 하단 내비게이션 미리보기" - className="sticky inset-x-0 bottom-0 z-30 flex items-center justify-around border-t border-black/10 bg-white/95 px-2 py-1.5 backdrop-blur-md" - > - {MOBILE_TABS.map(({key, label, anchor, icon: Icon}) => ( - <button - key={key} - type="button" - onClick={() => scrollToSection(anchor, key)} - className={cn( - 'flex cursor-pointer flex-col items-center justify-center rounded-lg px-2 py-1 transition-all', - activeMobileTab === key ? 'font-bold text-zinc-950' : 'text-zinc-500', - )} - > - <Icon className="size-4" /> - <span className="mt-0.5 text-[10px]">{label}</span> - </button> - ))} - <span - className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-[11px] font-bold text-white" - style={{backgroundColor: template.colors.primary}} - > - <Calendar className="size-3.5" /> - <span>예약하기</span> - </span> - </nav> - )} + {/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다 + (2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음") + 발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`, + 탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다). + 바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도 + 있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */} </div> </div> </div> diff --git a/solution/frontend/src/features/builder/FaqPanel.tsx b/solution/frontend/src/features/builder/FaqPanel.tsx index e9826e8..7d0b41d 100644 --- a/solution/frontend/src/features/builder/FaqPanel.tsx +++ b/solution/frontend/src/features/builder/FaqPanel.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import {useQueryClient} from '@tanstack/react-query'; import {Check, Loader2, MessageCircleQuestion, Plus, Sparkles, X} from 'lucide-react'; -import {FactStatus, PUBLISHABLE_FACT_STATUSES} from '@o2o/shared'; +import {FactStatus, PUBLISHABLE_FACT_STATUSES, SourceType} from '@o2o/shared'; import {getListFaqsQueryKey, useCreateFaq, useListFaqs, useTransitionFaq} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -59,6 +59,8 @@ function FaqEditor({placeId}: {placeId: string}) { const faqs = listQuery.data?.faqs ?? []; const publishable = listQuery.data?.publishable ?? 0; const pending = listQuery.data?.pending_review ?? 0; + // 20개를 채운 공통 질문. 답이 "숙소로 문의" 뿐이라 따로 센다 — 실제 답이 몇 개인지 보여야 채울 마음이 든다. + const inquiry = faqs.filter((faq) => faq.generated_by === SourceType.TEMPLATE).length; const submit = async () => { const q = question.trim(); @@ -95,7 +97,7 @@ function FaqEditor({placeId}: {placeId: string}) { <div className="flex items-center justify-between border-b border-border pb-2"> <span className="text-xs font-bold">자주 묻는 질문</span> <span className="text-[10px] text-muted-foreground"> - 노출 {publishable}건{pending > 0 && ` · 대기 ${pending}건`} + 노출 {publishable}건{inquiry > 0 && ` (문의 안내 ${inquiry})`}{pending > 0 && ` · 대기 ${pending}건`} </span> </div> @@ -160,7 +162,9 @@ function FaqEditor({placeId}: {placeId: string}) { <div className="flex items-start justify-between gap-2"> <p className="flex-1 text-xs font-semibold break-words">{faq.question}</p> {isLive ? ( - <span className="shrink-0 text-[10px] text-muted-foreground">노출 중</span> + <span className="shrink-0 text-[10px] text-muted-foreground"> + {faq.generated_by === SourceType.TEMPLATE ? '문의 안내' : '노출 중'} + </span> ) : ( <Badge variant="warning" className="shrink-0 text-[10px]"> 승인 대기 @@ -209,6 +213,13 @@ function FaqEditor({placeId}: {placeId: string}) { 직접 쓴 질문·답변은 <strong className="text-foreground">사장님이 확정한 값</strong>으로 바로 노출되고, AI 가 만들어 둔 항목은 승인해야 사이트와 AI 검색용 데이터에 나갑니다. </p> + {inquiry > 0 && ( + <p className="text-[11px] leading-relaxed text-muted-foreground"> + FAQ 가 20개에 모자라 손님이 자주 묻는 질문을 <strong className="text-foreground">문의 안내</strong>로 + 채웠습니다. 사이트에는 보이지만 AI 검색용 데이터에는 나가지 않습니다. 같은 질문에 직접 답을 추가하면 + 다음 생성 때 문의 안내 항목은 빠집니다. + </p> + )} </div> ); } diff --git a/solution/frontend/src/features/builder/SitePreview.tsx b/solution/frontend/src/features/builder/SitePreview.tsx index 56e58fd..ba3edae 100644 --- a/solution/frontend/src/features/builder/SitePreview.tsx +++ b/solution/frontend/src/features/builder/SitePreview.tsx @@ -19,6 +19,16 @@ */ import {useCallback, useEffect, useRef, useState} from 'react'; import type {ViewportMode} from '@o2o/shared'; +import {LoaderCircle} from 'lucide-react'; +import {onSiteThemeSaved} from '@/features/publish/siteTheme'; + +/** + * 미리보기가 그려지기를 기다리는 상한. 넘으면 로딩 표시를 걷고 iframe 을 그대로 보여준다. + * + * ★ 영영 안 걷히는 로딩바는 안 띄우느니만 못하다 — 구 번들이 올라가 있어 `o2o:preview-painted` + * 를 안 보내는 동안에도 편집은 돼야 한다(발행본 번들은 재발행 전까지 옛것일 수 있다). + */ +const PAINT_TIMEOUT_MS = 12000; /** * 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다. @@ -50,6 +60,67 @@ export function SitePreview({ const ref = useRef<HTMLIFrameElement>(null); const boxRef = useRef<HTMLDivElement>(null); const [scale, setScale] = useState(1); + const [painted, setPainted] = useState(false); + /** 저장이 끝날 때마다 올린다 — 값이 바뀌면 iframe 을 다시 띄워 새 payload 를 받는다. */ + const [reloadToken, setReloadToken] = useState(0); + /** 첫 로드인지. 첫 로드만 화면을 덮고, 편집 중 새로고침은 얇은 띠만 보여준다. */ + const firstPaintDone = useRef(false); + + /** + * 편집이 서버에 저장되면 미리보기를 다시 그린다. + * + * ★ 이게 없으면 섹션을 드래그해 순서를 바꿔도 **캔버스가 그대로**다. 왼쪽 목록만 움직이고 + * 사장님에게는 "드래그가 안 먹는" 것으로 보인다(실측 2026-09-15). 섹션 켜고 끄기· + * 배리에이션·색도 전부 같았다 — iframe 은 한 번 뜬 뒤 다시 그릴 계기가 없었다. + * ★ 스토어가 아니라 **저장 완료**를 듣는 이유는 siteTheme.onSiteThemeSaved 주석에 있다. + */ + useEffect(() => onSiteThemeSaved(() => setReloadToken((n) => n + 1)), []); + + /** + * 다시 그릴 때 보던 자리를 지킨다. 맨 위로 튀면 사장님은 방금 옮긴 섹션을 다시 찾아야 한다. + * ★ 첫 로드에는 복원할 것이 없으므로 건너뛴다. + */ + const scrollTop = useRef(0); + useEffect(() => { + if (!reloadToken) return; + const frame = ref.current; + if (!frame?.contentWindow) return; + scrollTop.current = frame.contentWindow.scrollY; + setPainted(false); + frame.contentWindow.location.reload(); + }, [reloadToken]); + + /** + * 미리보기가 **실제로 그려졌는지**. iframe 의 `load` 로는 알 수 없다 — + * 그때는 셸만 뜬 상태이고 payload fetch·웹폰트 대기가 남아 있어 화면은 아직 희다 + * (`site/src/entry-client.tsx` signalPreviewPainted 의 같은 주석). + * + * ★ 신호를 받지 못해도 상한(PAINT_TIMEOUT_MS)에서 걷는다. 구 번들이 올라가 있으면 + * 신호 자체가 없는데, 그 경우 로딩바가 영영 안 사라진다. + * ★ 출처를 확인한다 — 같은 오리진에서 온 우리 iframe 의 메시지만 받는다. + */ + useEffect(() => { + setPainted(false); + const frame = ref.current; + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.source !== frame?.contentWindow) return; + if ((event.data as {type?: string} | null)?.type !== 'o2o:preview-painted') return; + setPainted(true); + firstPaintDone.current = true; + // 다시 그린 경우에만 보던 자리로 돌려놓는다. + if (scrollTop.current) frame?.contentWindow?.scrollTo(0, scrollTop.current); + }; + window.addEventListener('message', onMessage); + const timer = setTimeout(() => { + setPainted(true); + firstPaintDone.current = true; + }, PAINT_TIMEOUT_MS); + return () => { + window.removeEventListener('message', onMessage); + clearTimeout(timer); + }; + }, [placeId, viewport, reloadToken]); /* * ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다. @@ -122,13 +193,32 @@ export function SitePreview({ return ( <div ref={boxRef} className="flex w-full justify-center overflow-hidden"> {/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */} - <div style={{width: w * scale, height: h * scale}}> + <div className="relative" style={{width: w * scale, height: h * scale}}> + {/* ★ iframe 을 덮는다(자리를 밀어내지 않는다). 레이아웃이 움직이면 그려지는 순간 + 화면이 튀고, 그 튐이 곧 "로딩이 끝났다" 보다 먼저 눈에 들어온다. + ★ 처음 들어올 때만 덮는다. 편집 중에는 옛 그림을 그대로 두고 위에 띠만 올린다 — + 섹션을 옮길 때마다 화면이 하얘지면 방금 무엇을 옮겼는지 놓친다. */} + {!painted && + (firstPaintDone.current ? ( + <div className="absolute inset-x-0 top-0 z-10" role="status" aria-live="polite"> + <LoaderCircle className="m-2 size-5 animate-spin text-primary" aria-label="미리보기 갱신 중" /> + </div> + ) : ( + <div + className="bg-background/95 absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 px-6" + role="status" + aria-live="polite" + > + <LoaderCircle className="size-8 animate-spin text-primary" aria-label="미리보기 준비 중" /> + <p className="text-muted-foreground text-xs">미리보기를 그리는 중입니다…</p> + </div> + ))} <iframe ref={ref} onLoad={wire} // ★ key 에 해상도를 넣어 바뀌면 새로 띄운다. 같은 문서를 리사이즈만 하면 이미 지나간 // 미디어 쿼리 분기(그리드 컬럼 수)가 그대로 남는 경우가 있다. - key={viewport} + key={`${placeId}:${viewport}`} title="발행본 미리보기" src={`/preview?placeId=${encodeURIComponent(placeId)}`} // ★ 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다. 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<string, SectionDataSpec> = { ], }, + 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/primitives/PlaceCarousel.tsx b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx index 9e75aaf..1d94492 100644 --- a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx +++ b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx @@ -8,6 +8,9 @@ import {walkMinutes} from '../variants/local/walking'; const naverSearch = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; +/** 네이버지도 검색 — 맛집·명소 카드가 쓴다(실제 사이트 LocalGuideSection과 동일한 규칙). */ +export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${encodeURIComponent(q)}`; + /** * 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기". * @@ -15,11 +18,21 @@ const naverSearch = (q: string) => * 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다. * ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다. */ -function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) { +function GuideCardView({ + card, + colors, + leading, + linkUrl = naverSearch, +}: { + card: GuideCard; + colors: TemplateItem['colors']; + leading?: ReactNode; + linkUrl?: (query: string) => string; +}) { const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined; return ( <a - href={naverSearch(card.searchQuery)} + href={linkUrl(card.searchQuery)} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} @@ -74,11 +87,14 @@ export function PlaceCarousel({ cards, colors, renderLeading, + linkUrl, }: { cards: GuideCard[]; colors: TemplateItem['colors']; /** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */ renderLeading?: (card: GuideCard) => ReactNode; + /** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */ + linkUrl?: (query: string) => string; }) { const trackRef = useRef<HTMLDivElement>(null); const [perPage, setPerPage] = useState(1); @@ -171,6 +187,7 @@ export function PlaceCarousel({ card={card} colors={colors} leading={renderLeading?.(card)} + linkUrl={linkUrl} /> ))} </div> diff --git a/solution/frontend/src/features/builder/canvas/primitives/index.ts b/solution/frontend/src/features/builder/canvas/primitives/index.ts index d949112..c444d8b 100644 --- a/solution/frontend/src/features/builder/canvas/primitives/index.ts +++ b/solution/frontend/src/features/builder/canvas/primitives/index.ts @@ -19,5 +19,5 @@ export {Pill} from './Pill'; export {CtaLink, type CtaStyle} from './CtaLink'; export {Rail} from './Rail'; /* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */ -export {PlaceCarousel} from './PlaceCarousel'; +export {PlaceCarousel, naverMapSearch} from './PlaceCarousel'; export {WalkFilterTabs} from './WalkFilterTabs'; diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index d1a59dc..420283a 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -65,6 +65,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'; @@ -415,6 +416,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = { ], + 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/common.ts b/solution/frontend/src/features/builder/canvas/variants/common.ts index ab27dd4..8e50472 100644 --- a/solution/frontend/src/features/builder/canvas/variants/common.ts +++ b/solution/frontend/src/features/builder/canvas/variants/common.ts @@ -44,6 +44,15 @@ export function introParagraph(_industryId: IndustryType, infoFields: InfoField[ return collected?.value?.trim() ?? ''; } +/** + * 소개 문구 축약본. `intro`/`room_intro` fact 가 길 때만 백엔드가 채워 보낸다(요청·응답에만 + * 실리고 DB 에는 남지 않는다). 없으면 빈 값 — 부르는 쪽이 원문(introParagraph)으로 떨어진다. + */ +export function introSummary(_industryId: IndustryType, infoFields: InfoField[]): string { + const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro'); + return collected?.summary?.trim() ?? ''; +} + /** * 이용 규정으로 읽히는 fact 들. 순서가 곧 화면 순서다. * diff --git a/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx b/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx index d8336af..f1bbaa1 100644 --- a/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx @@ -4,10 +4,13 @@ */ import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {primaryPhoto, introParagraph} from '../common'; +import {primaryPhoto, introParagraph, introSummary} from '../common'; export function IntroSideBySide(props: SectionRenderProps) { - const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields); + const intro = + props.section.body?.trim() || + introSummary(props.industryId, props.infoFields) || + introParagraph(props.industryId, props.infoFields); const {section, isSelected, onSelect, storeName, template, photos} = props; const shot = photos[1] ?? primaryPhoto(photos); // 사진이 없으면 회색 판만 남기고 <img> 는 렌더하지 않는다. diff --git a/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx b/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx index b5d617e..2bb23f3 100644 --- a/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx @@ -3,10 +3,13 @@ */ import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; import type {SectionRenderProps} from '../../types'; -import {introParagraph} from '../common'; +import {introParagraph, introSummary} from '../common'; export function IntroStory(props: SectionRenderProps) { - const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields); + const intro = + props.section.body?.trim() || + introSummary(props.industryId, props.infoFields) || + introParagraph(props.industryId, props.infoFields); const {section, isSelected, onSelect, storeName, template, photos} = props; const shotUrl = photos[1]?.url; diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx index 573bdef..d1f848b 100644 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx @@ -18,6 +18,7 @@ export function LocalCategorySection({ emptyText, colors, renderLeading, + linkUrl, }: { icon: LucideIcon; title: string; @@ -25,6 +26,7 @@ export function LocalCategorySection({ emptyText: string; colors: TemplateItem['colors']; renderLeading?: (card: GuideCard) => ReactNode; + linkUrl?: (query: string) => string; }) { const [filter, setFilter] = useState<WalkFilterKey>('all'); const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters)); @@ -52,7 +54,7 @@ export function LocalCategorySection({ {visible.length === 0 ? ( <EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice> ) : ( - <PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} /> + <PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} linkUrl={linkUrl} /> )} </> )} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx index 25a6fb5..217c752 100644 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx @@ -9,7 +9,7 @@ */ import {Calendar, MapPin, Utensils} from 'lucide-react'; import {useLocalGuide} from '@/hooks/useLocalGuide'; -import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; +import {Pill, SectionBody, SectionFrame, SectionHeading, naverMapSearch} from '../../primitives'; import type {SectionRenderProps} from '../../types'; import type {FestivalCard, GuideCard} from './types'; import {LocalCategorySection} from './LocalCategorySection'; @@ -46,12 +46,16 @@ export function LocalGuide(props: SectionRenderProps) { colors={colors} /> + {/* ★ 맛집은 사진이 없으면 목록에서 뺀다 (2026-09-15, 사장님 지시) — 명소는 이름 활자로 대신하는 + 규칙을 유지하지만, 맛집은 실제 사이트(LocalGuideSection)와 맞춰 사진 없는 곳을 숨긴다. */} + {/* ★ 맛집·명소 카드는 네이버지도 검색으로 연다 (2026-09-15, 사장님 지시) — 실제 사이트와 동일. */} <LocalCategorySection icon={Utensils} title="주변 맛집" - cards={foods} + cards={foods.filter((card) => card.imageUrl)} emptyText="주변 맛집·카페 추천은 아직 준비 중입니다." colors={colors} + linkUrl={naverMapSearch} /> <LocalCategorySection @@ -60,6 +64,7 @@ export function LocalGuide(props: SectionRenderProps) { cards={spots} emptyText="주변 명소 추천은 아직 준비 중입니다." colors={colors} + linkUrl={naverMapSearch} /> {isStay && ( 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 ( + <article + className="w-[300px] shrink-0 snap-center border p-4" + style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} + > + {item.group && ( + <p + className="text-[11px] font-bold tracking-[0.18em]" + style={{color: ITEM_ACCENT, fontFamily: ITEM_BODY}} + > + {item.group} + {item.year != null && <span className="font-normal opacity-70"> · {item.year}</span>} + </p> + )} + <h3 + className="mt-2 border-b-2 pb-2 text-lg leading-snug" + style={{fontFamily: ITEM_HEADING, borderColor: 'currentColor'}} + > + {item.title} + </h3> + <p + className="mt-2.5 text-[13px] leading-[1.8] break-keep opacity-80" + style={{fontFamily: ITEM_BODY}} + > + {item.body} + </p> + </article> + ); +} + +export function ReadingRail(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData<ReadingItem>(section.type, section.data); + + return ( + <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper"> + <SectionBody width="wide"> + <div className="space-y-1.5"> + <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}> + {parsed.title || section.name} + </h2> + {(parsed.subtitle || section.description) && ( + <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}> + {parsed.subtitle || section.description} + </p> + )} + </div> + + {parsed.error ? ( + <ParseError message={parsed.error} /> + ) : parsed.items.length === 0 ? ( + <PasteHint label="지역 읽기" /> + ) : ( + <div className="space-y-3"> + {/* 발행본과 에디터가 다르게 보이는 자리라, 몇 개가 나가는지 여기서 밝힌다. */} + <p className="text-[11px] opacity-60"> + 전체 {parsed.items.length}꼭지 · 발행본은 이 중 5~6개를 매번 무작위로 세웁니다 + </p> + <Rail label="지역 읽기" gap={0}> + {parsed.items.map((item, index) => ( + <Post key={`${item.title}-${index}`} item={item} /> + ))} + </Rail> + </div> + )} + </SectionBody> + </SectionFrame> + ); +} diff --git a/solution/frontend/src/features/builder/placeAdapter.ts b/solution/frontend/src/features/builder/placeAdapter.ts index f313a46..611cf48 100644 --- a/solution/frontend/src/features/builder/placeAdapter.ts +++ b/solution/frontend/src/features/builder/placeAdapter.ts @@ -155,6 +155,8 @@ function factField(fact: FactData, spec?: FieldSpecData): InfoField { isVerified: publishable, source: factSource(fact), critical: spec?.critical, + // 캔버스 미리보기용 축약문(intro/room_intro 가 길 때만 백엔드가 채워 보낸다). + summary: fact.summary?.trim() || undefined, }; } diff --git a/solution/frontend/src/features/onboarding/Step4Template.tsx b/solution/frontend/src/features/onboarding/Step4Template.tsx index fa7dfa9..40030a3 100644 --- a/solution/frontend/src/features/onboarding/Step4Template.tsx +++ b/solution/frontend/src/features/onboarding/Step4Template.tsx @@ -1,5 +1,8 @@ import {Check} from 'lucide-react'; +import {useRef, useState} from 'react'; import type {TemplateItem} from '@o2o/shared'; +import {getAccessToken, startCopy} from '@/api'; +import {notifyApiError} from '@/lib/notify'; import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {queueSiteTemplateSave} from '@/features/publish/siteTemplate'; import {cn} from '@/lib/utils'; @@ -87,7 +90,29 @@ export function Step4Template() { const placeId = useBuilderStore((s) => s.placeId); const selectTemplate = useBuilderStore((s) => s.selectTemplate); const [, goToStep] = useWizardStep(); - const startGenerating = useBuilderStore((s) => s.startGenerating); + const starting = useRef(false); + const [busy, setBusy] = useState(false); + + const startGeneration = async () => { + if (starting.current) return; + queueSiteTemplateSave(placeId, templateId); + if (!placeId || !getAccessToken()) { + goToStep('editor'); + return; + } + starting.current = true; + setBusy(true); + try { + const response = await startCopy(placeId, {}); + if (response.result?.success !== true || !response.job_id) throw {data: response}; + goToStep('generating', {params: {placeId, jobId: response.job_id}}); + } catch (error) { + notifyApiError(error, '소개문 생성을 시작하지 못했습니다.'); + } finally { + starting.current = false; + setBusy(false); + } + }; const config = INDUSTRY_CONFIGS[industry]; @@ -168,14 +193,9 @@ export function Step4Template() { <WizardFooter onPrev={() => goToStep('collect')} - onNext={() => { - // ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 — - // 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다. - queueSiteTemplateSave(placeId, templateId); - startGenerating(); - goToStep('generating'); - }} - nextLabel="이 템플릿으로 사이트 생성하기" + onNext={() => void startGeneration()} + nextDisabled={busy} + nextLabel={busy ? '생성 작업을 시작하고 있습니다' : '이 템플릿으로 사이트 생성하기'} /> </div> ); diff --git a/solution/frontend/src/features/onboarding/Step5Generating.tsx b/solution/frontend/src/features/onboarding/Step5Generating.tsx index 930c7fa..3a397c9 100644 --- a/solution/frontend/src/features/onboarding/Step5Generating.tsx +++ b/solution/frontend/src/features/onboarding/Step5Generating.tsx @@ -1,193 +1,58 @@ -import {useCallback, useEffect} from 'react'; import {Check, Loader2} from 'lucide-react'; import {JobStatus} from '@o2o/shared'; -import {delay, getAccessToken, pollJob, startCopy} from '@/api'; -import {Progress} from '@/components/ui/progress'; -import {notify, notifyApiError} from '@/lib/notify'; -import {cn} from '@/lib/utils'; +import {Button} from '@/components/ui/button'; import {useBuilderStore} from '@/stores/builder'; -import {EDITOR_STEP, useWizardStep} from './wizardUrl'; - -const BUILD_STEPS = [ - '수집된 사진 분류 및 대체 텍스트 생성', - '브랜드 컬러 시스템 및 타이포그래피 조합', - '확인된 사실만으로 소개 콘텐츠 구성', - '위치 기반 길찾기 및 주변 정보 연동', - '구조화 데이터(JSON-LD) 준비', -]; - -const STAGE_MS = 600; - -/** - * ★ 잡은 PENDING/RUNNING/DONE 세 상태만 준다 — 단계별 progress 필드가 없다. - * 그래서 문구는 "지금 서버가 이 근처를 하고 있다"는 안내이고, 진짜 신호는 잡 상태다. - * RUNNING 동안 마지막 단계 직전까지만 올리고, 완료는 오직 DONE 이 만든다 - * — 100%를 먼저 그려 놓고 기다리게 하지 않는다. - */ -const RUNNING_STAGE_CEILING = BUILD_STEPS.length - 1; +import {useGenerationJob} from './useGenerationJob'; +import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels'; export function Step5Generating() { const storeName = useBuilderStore((s) => s.storeName); - const stage = useBuilderStore((s) => s.generateStage); - const setGenerateStage = useBuilderStore((s) => s.setGenerateStage); const placeId = useBuilderStore((s) => s.placeId); - const [, goToStep] = useWizardStep(); - - /** - * 생성이 끝나면 곧바로 에디터다. - * - * ★ 중간에 다른 화면을 끼우지 않는다. 위저드를 끝까지 걸어온 사람의 다음 용건은 - * 자기 사이트를 보는 것이고, 목록을 한 번 거치게 하면 그게 한 번 더 클릭이 된다. - * 사업장은 이미 2단계에서 등록돼 목록에 들어가 있다 — '목록에 생기는 것'과 - * '목록으로 보내는 것'은 다른 얘기다. - */ - const finishOnboarding = useCallback(() => { - // ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인 - // 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다. - goToStep(EDITOR_STEP, {params: {flow: null}, replace: true}); - }, [goToStep]); - - /** - * 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다. - * - * ★ 정적 빌드(JobType.BUILD)는 여기가 아니라 발행 모달이 부른다 — 빌드는 발행 검수 - * 게이트와 한 몸이고(services/build_service), 여기서 미리 구워 봐야 편집 전 스냅샷이라 - * 버릴 버전만 쌓인다. - * ★ 폴백: placeId(데모 경로) 나 토큰이 없으면 백엔드를 아예 부르지 않고 타이머로 돈다. - */ - useEffect(() => { - const controller = new AbortController(); - - /** 타이머 시뮬레이션. 폴백 경로 전용 — 백엔드가 없어도 위저드는 끝까지 간다. */ - const runSimulation = async () => { - for (let next = 1; next <= BUILD_STEPS.length; next += 1) { - await delay(STAGE_MS, controller.signal); - if (controller.signal.aborted) return; - setGenerateStage(next); - } - await delay(STAGE_MS, controller.signal); - if (!controller.signal.aborted) finishOnboarding(); - }; - - /** RUNNING 인 동안 문구만 천천히 밀어 준다. 완료 판정은 하지 않는다. */ - const creep = () => - setGenerateStage( - Math.min(RUNNING_STAGE_CEILING, useBuilderStore.getState().generateStage + 1), - ); - - const runCopy = async (id: string) => { - let jobId: string; - try { - const started = await startCopy(id, {}, undefined, controller.signal); - // ★ 근거로 쓸 확인된 fact 가 0 이면 서버는 아무것도 쓰지 않는다 — LLM 은 사실을 만들지 않는다. - if (started.grounded_facts === 0) { - notify.warn( - '확인된 정보가 없어 소개문을 쓰지 않았습니다.', - '[확인] 탭에서 수집된 정보를 확정하면 그 사실만으로 소개문을 씁니다.', - ); - } - if (!started.job_id) { - await runSimulation(); - return; - } - jobId = started.job_id; - } catch (error) { - if (controller.signal.aborted) return; - notifyApiError(error, '소개문 생성을 시작하지 못했습니다.'); - await runSimulation(); - return; - } - - setGenerateStage(1); - const outcome = await pollJob(jobId, { - signal: controller.signal, - onTick: (job) => { - if (job.status === JobStatus.RUNNING) creep(); - }, - }); - - if (outcome.kind === 'aborted') return; - if (outcome.kind === 'dead') { - notify.error('소개문을 만들지 못했습니다.', outcome.job.last_error ?? undefined); - } else if (outcome.kind === 'unreachable') { - notifyApiError(outcome.error, '생성 상태를 확인하지 못했습니다.'); - } else if (outcome.kind === 'timeout') { - notify.warn('생성이 예상보다 오래 걸립니다.', '생성은 계속 진행됩니다.'); - } - - // ★ 실패해도 편집기로는 보낸다. 소개문은 없어도 확인된 fact 로 편집·발행이 되고, - // 여기서 멈추면 사장님은 되돌아갈 길이 없는 화면에 갇힌다. - setGenerateStage(BUILD_STEPS.length); - await delay(STAGE_MS, controller.signal); - if (!controller.signal.aborted) finishOnboarding(); - }; - - if (!placeId || !getAccessToken()) void runSimulation(); - else void runCopy(placeId); - - return () => controller.abort(); - }, [setGenerateStage, finishOnboarding, placeId]); - - const progress = Math.min(100, Math.round((stage / BUILD_STEPS.length) * 100)); + const {job, error, checkAgain, openEditor, goBack} = useGenerationJob(placeId); + const failed = job?.status === JobStatus.DEAD; + const waiting = job?.status === JobStatus.PENDING; + const steps = job?.progress?.steps ?? []; + const title = error ? '진행 상태 확인이 필요합니다' + : failed ? '콘텐츠 생성을 완료하지 못했습니다' + : waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다') + : job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다' + : '소개문과 FAQ를 만들고 있습니다'; return ( - <div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8"> - <div className="mx-auto flex w-full max-w-md flex-col items-center text-center"> - <div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card"> - <Loader2 className="size-8 animate-spin text-primary" /> - </div> - - <h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl"> - 웹사이트를 생성하고 있습니다 - </h1> - <p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm"> - <strong className="font-semibold text-foreground">{storeName}</strong>의 확인된 정보만 - 담아 정적 페이지로 굽고 있어요. + <div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-16"> + <div className="w-full max-w-md space-y-6 text-center"> + {!error && !failed && <Loader2 className="mx-auto size-8 animate-spin text-primary" />} + <h1 className="text-xl font-bold" aria-live="polite">{title}</h1> + <p className="text-sm text-muted-foreground"> + {error || <><strong>{storeName}</strong>의 확인된 정보를 바탕으로 작성합니다.</>} </p> - - <div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left"> - <div className="flex items-center justify-between text-xs"> - <span className="font-bold">{BUILD_STEPS[Math.min(stage - 1, BUILD_STEPS.length - 1)]}</span> - <span className="font-mono font-bold">{progress}%</span> - </div> - - <Progress value={progress} aria-label="사이트 생성 진행률" /> - - <ol className="space-y-2.5 pt-2"> - {BUILD_STEPS.map((text, index) => { - const num = index + 1; - const isDone = stage > num; - const isCurrent = stage === num; - + {steps.length > 0 && ( + <ol className="space-y-4 rounded-2xl border border-border bg-card p-6 text-left text-sm"> + {steps.map((step) => { + const state = step.status === 'running' && failed ? 'failed' : step.status; + const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀' + : state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기'; return ( - <li - key={text} - className={cn( - 'flex items-center gap-3 text-xs transition-colors', - isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground', - )} - > - <span - className={cn( - 'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', - isDone || isCurrent - ? 'bg-primary text-primary-foreground' - : 'border border-border bg-muted', - isCurrent && 'ring-2 ring-primary/20', - )} - > - {isDone ? <Check className="size-2.5" strokeWidth={3} /> : num} - </span> - <span className="truncate">{text}</span> + <li key={step.id} className="flex items-center gap-3"> + {state === 'done' ? <Check className="size-4 shrink-0 text-success" /> + : state === 'running' && !waiting && !error ? <Loader2 className="size-4 shrink-0 animate-spin" /> + : <span className="size-4 shrink-0 rounded-full border border-border" />} + <div className="flex-1"> + {GENERATION_LABELS[step.id] ?? '콘텐츠 처리'} + {step.reason && <p className="mt-1 text-xs text-muted-foreground">{SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}</p>} + </div> + <span className="text-xs text-muted-foreground">{label}</span> </li> ); })} </ol> - </div> - - <p className="mt-6 text-xs text-muted-foreground"> - 생성이 끝나면 실시간 에디터로 자동 전환됩니다. - </p> + )} + {!error && !failed && <p className="text-xs text-muted-foreground">새로고침해도 같은 작업의 진행 상태를 이어서 확인합니다.</p>} + {(error || failed) && <div className="flex flex-wrap justify-center gap-2"> + {error && <Button onClick={checkAgain}>상태 다시 확인</Button>} + <Button variant="outline" onClick={goBack}>이전 단계로</Button> + <Button variant="outline" onClick={openEditor}>편집기로 이동</Button> + </div>} </div> </div> ); diff --git a/solution/frontend/src/features/onboarding/collectNotify.ts b/solution/frontend/src/features/onboarding/collectNotify.ts index 4a167cc..7daf712 100644 --- a/solution/frontend/src/features/onboarding/collectNotify.ts +++ b/solution/frontend/src/features/onboarding/collectNotify.ts @@ -55,19 +55,27 @@ export function notifyVision(job: JobData) { ); } -/** 재생성(소개문·FAQ) 결과 알림. 생성물도 미검증으로 들어가 사람이 승인해야 나간다. */ +/** + * 재생성(소개문·FAQ) 결과 알림. + * + * ★ `faq_fill` 을 같이 본다. fact 가 0건이면 AI 가 쓴 FAQ(`faqs`)는 0 이지만 문의 안내로 20개가 채워진다 — + * `faqs` 만 보면 "새로 만든 문장이 없습니다" 가 떠서, FAQ 탭에 20개가 있는데 실패로 읽힌다. + */ export function notifyCopy(job: JobData) { const faqCount = readNumber(job.result, 'faqs'); + const fillCount = readNumber(job.result, 'faq_fill'); const hasIntro = (job.result as {intro?: unknown} | undefined)?.intro === true; - if (!hasIntro && faqCount === 0) { + if (!hasIntro && faqCount === 0 && fillCount === 0) { notify.warn( '새로 만든 문장이 없습니다.', readNote(job) ?? '근거로 쓸 확인된 정보가 부족합니다. 정보를 먼저 확인해 주세요.', ); return; } + const parts = [`소개문 ${hasIntro ? '1건' : '없음'}`, `FAQ ${faqCount}건`]; + if (fillCount > 0) parts.push(`문의 안내 ${fillCount}건`); notify.success( - '소개문을 다시 만들었습니다', - `소개문 ${hasIntro ? '1건' : '없음'} · FAQ ${faqCount}건 — 확인하신 뒤 사이트에 나갑니다.`, + '소개문·FAQ 를 다시 만들었습니다', + `${parts.join(' · ')} — FAQ 탭에서 확인할 수 있습니다.`, ); } diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts index 0c88d1c..682caa5 100644 --- a/solution/frontend/src/features/onboarding/ensureServerPlace.ts +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -57,7 +57,14 @@ export async function ensureServerPlace( // 그래도 없으면 검증 없이 둔다 — 수집은 열리지 않고, 화면이 붙여넣기를 요청한다. if (!url) return {...identity, placeId}; - const res = await verifyPlaceByUrl(placeId, {url}); + /* + * ★ `reuse_existing: false` — 이 함수는 **새로 만들기 경로에서만** 온다 + * (위의 `if (identity.placeId) return identity` 가 이어하기를 이미 걸렀다). + * 빼면 서버가 같은 네이버 place id 를 가진 기존 사업장을 찾아 그쪽으로 돌려보내고, + * 사장님은 [새로 크롤링하고 사이트 생성하기] 를 눌렀는데 **기존 에디터**를 만난다 + * (실측 2026-09-15). 빈 중복 행 정리는 서버가 이 값과 무관하게 계속 한다. + */ + const res = await verifyPlaceByUrl(placeId, {url, reuse_existing: false}); const place = res.place; if (!place) { notifyApiError({data: res}, '그 주소에서 가게 정보를 읽지 못했습니다.'); diff --git a/solution/frontend/src/features/onboarding/generationLabels.ts b/solution/frontend/src/features/onboarding/generationLabels.ts new file mode 100644 index 0000000..a79d7bf --- /dev/null +++ b/solution/frontend/src/features/onboarding/generationLabels.ts @@ -0,0 +1,13 @@ +/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */ +export const GENERATION_LABELS: Record<string, string> = { + prepare: '확인된 정보와 수집 원문 준비', + generate: '소개문·FAQ 생성 및 근거 검증', + save: '소개문·FAQ 저장', + faq_fill: '부족한 FAQ 문의 안내 보완', +}; + +export const SKIP_REASONS: Record<string, string> = { + no_facts: '확인된 정보 없음', + not_configured: '생성 서비스 미설정', + no_catalog: '공통 질문 대상 업종 아님', +}; diff --git a/solution/frontend/src/features/onboarding/useGenerationJob.ts b/solution/frontend/src/features/onboarding/useGenerationJob.ts new file mode 100644 index 0000000..99d88cb --- /dev/null +++ b/solution/frontend/src/features/onboarding/useGenerationJob.ts @@ -0,0 +1,75 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useSearchParams} from 'react-router'; +import {useQueryClient} from '@tanstack/react-query'; +import {JobStatus, JobType} from '@o2o/shared'; +import {getAccessToken, startCopy, useGetJob} from '@/api'; +import {EDITOR_STEP, useWizardStep} from './wizardUrl'; + +/** URL의 jobId 복구 → 서버 상태 조회 → 완료 시 편집기. 시간으로 단계를 추정하지 않는다. */ +export function useGenerationJob(placeId: string | null) { + const [params] = useSearchParams(); + const [, goToStep] = useWizardStep(); + const jobId = params.get('jobId'); + const queryClient = useQueryClient(); + const [startError, setStartError] = useState<string | null>(null); + const [retry, setRetry] = useState(0); + const authenticated = Boolean(getAccessToken()); + + const query = useGetJob(jobId ?? '', {query: { + enabled: Boolean(placeId && jobId && authenticated), + retry: 2, + refetchInterval: (current) => { + const status = current.state.data?.job?.status; + return current.state.status === 'error' || status === JobStatus.DONE || status === JobStatus.DEAD + ? false : 2000; + }, + }}); + const job = query.data?.job; + const wrongJob = Boolean(job && (job.job_type !== JobType.COPY || job.place_id !== placeId)); + + const openEditor = useCallback(() => { + goToStep(EDITOR_STEP, {params: {flow: null, jobId: null}, replace: true}); + }, [goToStep]); + + useEffect(() => { + if (!placeId || jobId || !authenticated) return; + const controller = new AbortController(); + // 구 주소이거나 시작 응답을 받기 전에 새로고침했으면 완료 이력까지 찾아 이어 본다. + void startCopy(placeId, {resume: true}, undefined, controller.signal).then((response) => { + if (controller.signal.aborted) return; + if (response.result?.success !== true || !response.job_id) { + setStartError('생성을 시작하지 못했습니다. 정보를 확인한 뒤 다시 시도해 주세요.'); + return; + } + goToStep('generating', {params: {jobId: response.job_id, placeId}, replace: true}); + }).catch(() => { + if (!controller.signal.aborted) setStartError('서버에 연결하지 못했습니다. 다시 확인해 주세요.'); + }); + return () => controller.abort(); + }, [placeId, jobId, authenticated, goToStep, retry]); + + useEffect(() => { + if (wrongJob || job?.status !== JobStatus.DONE) return; + let active = true; + void queryClient.invalidateQueries({predicate: (cached) => + typeof cached.queryKey[0] === 'string' && cached.queryKey[0].startsWith(`/v1/place/${placeId}`), + }).then(() => { if (active) openEditor(); }); + return () => { active = false; }; + }, [job?.status, wrongJob, placeId, queryClient, openEditor]); + + const error = !placeId ? '사업장 정보가 없습니다. 이전 단계에서 사업장을 선택해 주세요.' + : !authenticated ? '로그인이 필요합니다. 편집기로 이동해 로그인해 주세요.' + : wrongJob ? '이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.' + : startError ?? (query.isError || (query.isSuccess && !job) + ? '진행 상태를 확인하지 못했습니다. 서버 작업은 계속될 수 있습니다.' : null); + + return { + job, error, openEditor, + checkAgain: () => { + setStartError(null); + if (jobId) void query.refetch(); + else setRetry((value) => value + 1); + }, + goBack: () => goToStep('template', {params: {jobId: null}}), + }; +} diff --git a/solution/frontend/src/features/onboarding/wizardQuery.ts b/solution/frontend/src/features/onboarding/wizardQuery.ts new file mode 100644 index 0000000..0f4825a --- /dev/null +++ b/solution/frontend/src/features/onboarding/wizardQuery.ts @@ -0,0 +1,13 @@ +export function editorParams(input: URLSearchParams, savedPlaceId: string | null): URLSearchParams { + const params = new URLSearchParams(input); + const placeId = params.get('placeId') || savedPlaceId; + params.delete('flow'); + params.delete('jobId'); + if (placeId) { + params.set('placeId', placeId); + params.delete('step'); + } else { + params.set('step', 'search'); + } + return params; +} diff --git a/solution/frontend/src/features/onboarding/wizardUrl.ts b/solution/frontend/src/features/onboarding/wizardUrl.ts index 5190181..a48ef0f 100644 --- a/solution/frontend/src/features/onboarding/wizardUrl.ts +++ b/solution/frontend/src/features/onboarding/wizardUrl.ts @@ -1,5 +1,7 @@ -import {useCallback} from 'react'; +import {useCallback, useEffect} from 'react'; import {useSearchParams} from 'react-router'; +import {useBuilderStore} from '@/stores/builder'; +import {editorParams} from './wizardQuery'; /** * 위저드 단계는 **주소창이 소유한다** — `/builder?step=<이름>`. @@ -72,8 +74,15 @@ interface GoOptions { */ export function useWizardStep(): [WizardStep, (next: WizardStep, options?: GoOptions) => void] { const [searchParams, setSearchParams] = useSearchParams(); + const savedPlaceId = useBuilderStore((state) => state.confirmedIdentity?.placeId ?? state.placeId); const step = parseStep(searchParams.get('step')) ?? defaultStep(searchParams); + // 옛 편집기 주소도 새로고침 가능한 사업장 주소로 정정한다. + useEffect(() => { + if (step !== EDITOR_STEP || searchParams.get('step') !== EDITOR_STEP) return; + setSearchParams((prev) => editorParams(prev, savedPlaceId), {replace: true}); + }, [step, searchParams, savedPlaceId, setSearchParams]); + const go = useCallback( (next: WizardStep, options?: GoOptions) => { setSearchParams( @@ -84,12 +93,13 @@ export function useWizardStep(): [WizardStep, (next: WizardStep, options?: GoOpt if (value === null) params.delete(key); else params.set(key, value); } + if (next === EDITOR_STEP) return editorParams(params, savedPlaceId); return params; }, {replace: options?.replace}, ); }, - [setSearchParams], + [setSearchParams, savedPlaceId], ); return [step, go]; diff --git a/solution/frontend/src/features/publish/siteTheme.ts b/solution/frontend/src/features/publish/siteTheme.ts index d8f69b1..61126bb 100644 --- a/solution/frontend/src/features/publish/siteTheme.ts +++ b/solution/frontend/src/features/publish/siteTheme.ts @@ -134,6 +134,23 @@ let saveChain: Promise<void> = Promise.resolve(); let saveSeq = 0; let timer: ReturnType<typeof setTimeout> | undefined; +/** + * 저장이 **서버에 닿은 뒤** 알린다. 미리보기(iframe)가 이 신호를 듣고 다시 그린다. + * + * ★ 왜 스토어 구독이 아니라 여기인가 — 미리보기는 `GET /site/preview` 로 **서버에서** + * payload 를 받는다. 스토어가 바뀐 순간 다시 그리면 아직 저장 전이라 옛 값을 받아 오고, + * 화면은 "드래그해도 그대로" 가 된다. 저장 완료가 다시 그릴 수 있는 가장 이른 시점이다. + * ★ 실패하면 알리지 않는다 — 서버가 옛 값이면 다시 그려 봐야 옛 그림이다. + */ +type ThemeSavedListener = () => void; +const savedListeners = new Set<ThemeSavedListener>(); + +/** 구독 해제 함수를 돌려준다. */ +export function onSiteThemeSaved(listener: ThemeSavedListener): () => void { + savedListeners.add(listener); + return () => savedListeners.delete(listener); +} + /** * 디자인 변경을 저장하되 **화면을 기다리게 하지 않는다.** * @@ -149,6 +166,8 @@ export function queueSiteThemeSave(placeId: string | null, theme: SiteThemePaylo saveChain = saveChain.then(async () => { try { await saveSiteTheme(placeId, theme); + // 뒤에 올라탄 저장이 있으면 그것이 끝날 때 알린다 — 중간 상태로 두 번 그리지 않는다. + if (seq === saveSeq) savedListeners.forEach((listener) => listener()); } catch (error) { if (seq !== saveSeq) return; // 이미 다른 편집이 올라탔다 — 그쪽 결과만 알린다. notifyApiError(error, '디자인 설정을 저장하지 못했습니다. 발행 전에 다시 확인해 주세요.'); diff --git a/solution/frontend/src/features/publish/usePublishSite.ts b/solution/frontend/src/features/publish/usePublishSite.ts index 2862ef8..42165d1 100644 --- a/solution/frontend/src/features/publish/usePublishSite.ts +++ b/solution/frontend/src/features/publish/usePublishSite.ts @@ -157,8 +157,11 @@ export function usePublishSite(placeId: string | null): UsePublishSiteResult { return; } if (outcome.kind === 'timeout') { - notify.warn('빌드가 예상보다 오래 걸립니다.', '빌드는 계속 진행됩니다.'); - setState({phase: 'failed'}); + // ★ 실패가 아니다 — 잡은 서버에서 계속 돈다. 화면만 풀어 주고 상태는 idle 로 되돌린다. + // 'failed' 로 두면 "빌드는 계속 진행됩니다" 라고 말하면서 실패 화면을 그린다. + notify.warn('발행이 예상보다 오래 걸립니다.', '발행은 계속 진행됩니다 — 잠시 뒤 새로고침해 확인해 주세요.'); + setState(IDLE); + void queryClient.invalidateQueries({queryKey: getGetSiteQueryKey(id)}); return; } diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx index cece0f4..329b9a7 100644 --- a/solution/frontend/src/root.tsx +++ b/solution/frontend/src/root.tsx @@ -15,6 +15,7 @@ const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai export const ORIGIN = `https://${PUBLISH_HOST}`; export const links: LinksFunction = () => [ + {rel: 'icon', type: 'image/png', sizes: '32x32', href: '/brand/favicon-w4a.png'}, {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg?v=14'}, // self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 {rel: 'preconnect', href: 'https://fonts.googleapis.com'}, diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index 2adaa0d..f7609a5 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -74,7 +74,6 @@ interface BuilderState { // 템플릿 · 생성 templateId: string; colorPaletteId: string | null; - generateStage: number; // ── 편집 대상 데이터 ─────────────────────────────────── sections: SectionItem[]; @@ -123,8 +122,6 @@ interface BuilderState { selectTemplate: (templateId: string) => void; selectColorPalette: (paletteId: string | null) => void; /** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */ - startGenerating: () => void; - setGenerateStage: (stage: number) => void; selectSection: (id: string | null) => void; toggleSection: (id: string) => void; @@ -300,7 +297,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({ isGathering: false, gatherStage: 1, gatherCompleted: false, - generateStage: 1, selectedSectionId: null, viewport: 'pc', @@ -514,8 +510,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({ persistTheme(); }, // 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다. - startGenerating: () => set({generateStage: 1}), - setGenerateStage: (generateStage) => set({generateStage}), selectSection: (selectedSectionId) => set({selectedSectionId}), @@ -785,7 +779,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({ isGathering: false, gatherStage: 1, gatherCompleted: false, - generateStage: 1, selectedSectionId: null, isPreviewMode: false, isPublishModalOpen: false, diff --git a/solution/frontend/tests/editor-url.test.mjs b/solution/frontend/tests/editor-url.test.mjs new file mode 100644 index 0000000..10cebfe --- /dev/null +++ b/solution/frontend/tests/editor-url.test.mjs @@ -0,0 +1,15 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {editorParams} from '../src/features/onboarding/wizardQuery.ts'; + +test('편집기 진입은 사업장 ID만 남긴다', () => { + const result = editorParams(new URLSearchParams('step=editor&flow=onboarding&jobId=old'), 'place-1'); + assert.equal(result.toString(), 'placeId=place-1'); +}); +test('주소의 사업장이 저장된 다른 사업장보다 우선이다', () => { + const result = editorParams(new URLSearchParams('step=editor&placeId=place-2'), 'place-1'); + assert.equal(result.toString(), 'placeId=place-2'); +}); +test('사업장 없는 편집기 주소는 검색 단계로 보낸다', () => { + assert.equal(editorParams(new URLSearchParams('step=editor'), null).toString(), 'step=search'); +}); diff --git a/solution/frontend/tests/generation.mjs b/solution/frontend/tests/generation.mjs new file mode 100644 index 0000000..331a1ed --- /dev/null +++ b/solution/frontend/tests/generation.mjs @@ -0,0 +1,125 @@ +// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL] +// 모든 API는 가짜 응답으로 막는다. 외부 생성 API·실제 사업장에는 쓰지 않는다. +import assert from 'node:assert/strict'; +import {chromium} from 'playwright'; + +const origin = process.argv[2] ?? 'http://127.0.0.1:3015'; +const placeId = '10000000-0000-4000-8000-000000000001'; +const jobId = '20000000-0000-4000-8000-000000000001'; +const result = {success: true, code: 0}; +const browser = await chromium.launch({channel: 'chrome'}); +let passed = 0; + +async function scenario(name, run) { + const page = await browser.newPage(); + page.on('pageerror', (error) => console.error(error.message)); + const state = { + posts: [], gets: 0, unavailable: false, + job: {job_id: jobId, place_id: placeId, job_type: 3, status: 2, attempts: 1, + progress: {attempt: 1, steps: [ + {id: 'prepare', status: 'done'}, {id: 'generate', status: 'running'}, + {id: 'save', status: 'pending'}, {id: 'faq_fill', status: 'pending'}, + ]}}, + }; + await page.addInitScript(() => localStorage.setItem('o2o-web4ai.accessToken', 'test-token')); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (!url.pathname.startsWith('/v1/')) { + return url.origin === origin ? route.continue() : route.abort(); + } + let body = {result}; + if (url.pathname === `/v1/job/${jobId}`) { + state.gets += 1; + if (state.unavailable) return route.fulfill({status: 503, json: {}}); + body.job = state.job; + } else if (url.pathname.endsWith('/copy')) { + state.posts.push(route.request().postDataJSON()); + body = {...body, job_id: jobId, created: true, status: state.job.status}; + } else if (url.pathname === `/v1/place/${placeId}`) { + body.place = {place_id: placeId, name: '진행 복구 테스트', category: 1, status: 3, + verified_at: '2026-09-15T00:00:00Z', road_address: '서울 강남구'}; + } else if (url.pathname.endsWith('/schema')) body.fields = []; + else if (url.pathname.endsWith('/fact/list')) body.facts = []; + else if (url.pathname.endsWith('/media')) body.media = []; + else if (url.pathname.endsWith('/site')) body.site = {site_id: 'test-site', status: 1, template_id: 'stay.oasi'}; + else if (url.pathname.endsWith('/auth/me')) body = {...body, user_id: 'test-owner', id: 'test', role: 1}; + await route.fulfill({json: body}); + }); + const open = (suffix = `&jobId=${jobId}`) => page.goto(`${origin}/builder?step=generating&flow=onboarding&placeId=${placeId}${suffix}`); + try { + await run(page, state, open); + console.log(`PASS ${name}`); + passed += 1; + } catch (error) { + console.error(await page.locator('body').innerText()); + console.error(JSON.stringify({gets: state.gets, posts: state.posts})); + throw error; + } finally { + await page.close(); + } +} + +try { + await scenario('새로고침은 같은 잡 조회만, 서버가 멈추면 표시도 그대로', async (page, state, open) => { + await open(); + await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); + await page.reload(); + await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); + await page.waitForFunction(() => document.querySelectorAll('ol li').length === 4); + await page.waitForTimeout(2300); + assert.equal(state.posts.length, 0); + assert.ok(state.gets >= 3); + assert.match(await page.locator('ol li').nth(1).innerText(), /진행 중/); + assert.match(await page.locator('ol li').nth(2).innerText(), /대기/); + assert.equal(await page.getByText(/\d+%/).count(), 0); + }); + + await scenario('구 URL은 resume으로 복구하고 잡 ID를 주소에 보존', async (page, state, open) => { + await open(''); + await page.waitForURL(`**jobId=${jobId}`); + assert.ok(state.posts.length > 0); + assert.ok(state.posts.every((body) => body.resume === true)); + const count = state.posts.length; + await page.reload(); + await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); + assert.equal(state.posts.length, count); + }); + + await scenario('완료된 잡 재접속은 재생성 없이 편집기로', async (page, state, open) => { + state.job.status = 3; + await open(); + await page.waitForURL('**step=editor**'); + assert.equal(new URL(page.url()).searchParams.has('jobId'), false); + assert.equal(state.posts.length, 0); + }); + + await scenario('실패는 완료로 표시하거나 자동으로 편집기로 보내지 않음', async (page, state, open) => { + state.job.status = 4; + await open(); + await page.getByRole('heading', {name: '콘텐츠 생성을 완료하지 못했습니다'}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get('step'), 'generating'); + assert.match(await page.locator('ol li').nth(1).innerText(), /중단/); + assert.equal(state.posts.length, 0); + }); + + await scenario('통신 오류 뒤에는 같은 잡 조회만 재시도', async (page, state, open) => { + state.unavailable = true; + await open(); + await page.getByRole('button', {name: '상태 다시 확인'}).waitFor(); + state.unavailable = false; + await page.getByRole('button', {name: '상태 다시 확인'}).click(); + await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); + assert.equal(state.posts.length, 0); + }); + + await scenario('다른 사업장의 잡 ID는 완료여도 이동 금지', async (page, state, open) => { + state.job.place_id = 'other-place'; + state.job.status = 3; + await open(); + await page.getByText('이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.').waitFor(); + assert.equal(new URL(page.url()).searchParams.get('step'), 'generating'); + }); + console.log(`${passed} scenarios passed`); +} finally { + await browser.close(); +} diff --git a/solution/shared/src/lib/facts.ts b/solution/shared/src/lib/facts.ts index 82f1e59..26808d2 100644 --- a/solution/shared/src/lib/facts.ts +++ b/solution/shared/src/lib/facts.ts @@ -1,5 +1,6 @@ import { isPublishableFact, + SourceType, type FactEntry, type FaqEntry, type SitePayload, @@ -83,13 +84,24 @@ export function missingRequiredFacts(facts: FactEntry[]): FactEntry[] { ); } -/** 노출 가능한 FAQ 만. FAQPage JSON-LD 의 입력이기도 하다. */ +/** 노출 가능한 FAQ 만 — 화면에 그리는 목록. 문의 안내(TEMPLATE)도 들어간다. */ export function selectPublishableFaqs(faqs: FaqEntry[]): FaqEntry[] { return faqs .filter((f) => isPublishableFact(f.status) && f.question && f.answer) .sort((a, b) => a.sortOrder - b.sortOrder); } +/** + * 실제로 답하는 FAQ 만 — FAQPage JSON-LD · llms.txt 의 입력. + * + * ★ 문의 안내(TEMPLATE)를 뺀다. FAQ 를 20개로 채우려고 붙인 공통 질문이라 답이 + * "숙소로 문의 부탁드립니다" 뿐이다. 구조화 데이터로 내보내면 AI 검색이 인용할 답이 없는 + * 문항이 섞이고, 같은 문구가 모든 펜션 사이트에 반복된다. + */ +export function selectAnsweredFaqs(faqs: FaqEntry[]): FaqEntry[] { + return selectPublishableFaqs(faqs).filter((f) => f.sourceType !== SourceType.TEMPLATE); +} + /** 단위(객실·메뉴) 안의 fact 도 같은 규칙으로 거른 사본을 준다. */ export function sanitizeUnits(units: UnitInfo[]): UnitInfo[] { return units 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<string, string> = { 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..0167646 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', ]; @@ -84,6 +92,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = { [이 아이템만의 규칙] · lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. · labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. + **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.** + 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15: + 67곡 중 63곡이 비어 있었다). · 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다. `, }, @@ -157,6 +168,36 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = { `, }, + 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/builder.ts b/solution/shared/src/types/builder.ts index d06bf7f..a0741f9 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -15,6 +15,9 @@ export interface InfoField { id: string; label: string; value: string; + /** 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 백엔드가 채워 보낸다(응답 전용, + * place_facts 에는 저장되지 않는다). 없으면 값이 짧거나 요약에 실패한 것 — 원문(value)으로 폴백한다. */ + summary?: string; /** 수집됐지만 사장님 확인이 필요한 값(백엔드 FactStatus.UNVERIFIED / PENDING_OWNER 대응). */ requiresVerification: boolean; /** [맞아요] 또는 수정 승인을 거쳤는가(VERIFIED / CORRECTED 대응). */ diff --git a/solution/shared/src/types/domain.ts b/solution/shared/src/types/domain.ts index a612cdb..16f4749 100644 --- a/solution/shared/src/types/domain.ts +++ b/solution/shared/src/types/domain.ts @@ -32,6 +32,8 @@ export const SourceType = { API: 2, CRAWL: 3, LLM: 4, + /** FAQ 전용 — 목표 수를 채운 공통 질문 + 문의 안내 답(backend services/faq_fill). 사실이 아니다. */ + TEMPLATE: 5, } as const; export type SourceType = (typeof SourceType)[keyof typeof SourceType]; diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index e149fe2..b4c8eee 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -57,6 +57,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 { @@ -71,8 +86,16 @@ export interface SiteMeta { /** ISO8601. `<meta name="dateModified">` 와 JSON-LD `dateModified` 로 나간다 — AI 검색이 신선도를 본다. */ publishedAt: string; updatedAt: string; - /** 사이트 버전. 빌드 산출물 캐시 무효화 키. */ + /** 사이트 버전. 빌드 산출물 캐시 무효화 키이자 `out/versions/<slug>/<version>/` 산출 경로. */ version: number; + /** + * 이 렌더가 **공개 주소(`/s/<slug>`)를 이 버전으로 넘겨도 되는가**. + * + * ★ false(미리보기·재빌드)면 렌더러가 `out/versions/<slug>/<version>/` 에만 굽고 + * 공개 심볼릭 링크는 건드리지 않는다 — 방문자는 계속 직전 버전을 본다. + * BUILD 잡의 `publish=false` 가 그대로 여기로 온다(services/site_payload._publish_target). + */ + publish: boolean; } export interface PlaceInfo { @@ -118,6 +141,8 @@ export interface FactEntry { key: string; label: string; value: string | null; + /** 긴 숙소소개를 정보 표에 표시할 축약문. 없으면 value 원문을 표시한다. */ + summary?: string; unit?: string | null; type: 'text' | 'number' | 'bool' | 'time' | 'date'; scope: 'place' | 'unit'; @@ -168,6 +193,13 @@ export interface FaqEntry { } export interface ChannelLink { + /** 확정된 NOL 숙소 링크의 수집 안내 원문. 기존 payload는 생략 가능하다. */ + stayGuide?: { + fields?: {key: string; label: string; value: string; group: 'rules' | 'facilities'; note?: string}[]; + service?: string; + policy?: string; + reservation?: string; + }; channel: LinkChannel; url: string; title?: string; @@ -182,6 +214,7 @@ import type { PeopleItem, PostcardItem, QuizItem, + ReadingItem, SongItem, } from '../lib/section-data'; @@ -200,7 +233,7 @@ export interface LocalContents { */ itineraries?: ItineraryItem[]; /** - * 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·엽서·퀴즈. + * 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈. * * ★ 사장님이 붙여넣는 같은 종류의 JSON(`theme.sections[].data`)과 **모양이 같다.** * 화면은 둘을 한 배열로 이어 그린다(`StorySection`) — 사장님 값이 앞이다. @@ -213,12 +246,13 @@ export interface LocalContents { syncedAt?: string; } -/** 지역 이야기 여섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ +/** 지역 이야기 일곱 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ export interface LocalStories { songs?: SongItem[]; daily?: DailyItem[]; people?: PeopleItem[]; chronicle?: ChronicleItem[]; + reading?: ReadingItem[]; postcard?: PostcardItem[]; quiz?: QuizItem[]; } @@ -255,6 +289,8 @@ export interface WeatherSnapshot { * ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다. */ notes?: Partial<Record<WeatherMood, string>>; + noteSets?: Partial<Record<WeatherMood | '구름많음', string[]>>; + tempNoteSets?: Partial<Record<WeatherBand, string[]>>; /** * 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.** * @@ -272,6 +308,8 @@ export interface WeatherSnapshot { export interface LocalPlace { name: string; category: string; + /** 수집 출처가 제공한 장소 주소. */ + location?: string; /** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */ distanceText?: string; /** diff --git a/solution/site/scripts/mockup/AUTOPLAY.md b/solution/site/scripts/mockup/AUTOPLAY.md new file mode 100644 index 0000000..666faf6 --- /dev/null +++ b/solution/site/scripts/mockup/AUTOPLAY.md @@ -0,0 +1,151 @@ +# 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지 + +> ★ **2026-09-15 — 자동재생을 껐다.** 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 +> 사람들이 에러로 보잖아 그냥 꺼놔." 아래 내용은 **왜 자동재생이 어려운가**의 기록으로 남긴다 +> — 되살릴 일이 생기면 `inject.js` 의 `void autoplay;` 를 `autoplay();` 로 되돌리면 된다. +> 함수와 첫 손길 처리는 지우지 않았다. + +`/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다. +같은 얘기가 다시 나오면 이 파일부터 본다. + +측정: 2026-09-14 · 킹서버 `https://web4ai.o2osolution.ai/s/stay` · Chrome(정식 채널) + +--- + +## 결론 + +**브라우저에서 첫 방문에 소리가 저절로 나게 하는 방법은 없다.** +사람 손 입력(`isTrusted: true`) 없이는 크롬이 `play()` 를 `NotAllowedError` 로 거절한다. +자바스크립트로 그 검사를 통과시키는 길은 없고, 그건 우회 대상이 아니라 +**그 우회를 막으려고 만들어진 검사**다. + +지금 동작: **손님이 화면에 처음 닿으면(클릭·탭·키) 켜진다.** 그 이상은 어떤 사이트도 못 한다. + +### 해 본 것과 결과 + +| 시도 | 결과 | +|---|---| +| 로드 즉시 `audio.play()` | `NotAllowedError` | +| 무음(`muted=true`)으로 시작 → 나중에 unmute | `NotAllowedError` (무음도 거절된다) | +| `<audio muted playsinline>` 태그로 두고 `play()` | `NotAllowedError` | +| 가짜 이벤트 강제 발생 — `dispatchEvent` 로 `pointerdown`·`mousedown`·`pointerup`·`mouseup`·`click`·`keydown` 6종 | `navigator.userActivation` **false 그대로**, 거절 | +| 재생 단추를 코드로 누르기 — `btn.click()`, 0.7초 간격 3회 | `play=0` | +| `--app=URL` 앱 모드로 띄우기 | `play=0` | +| **Web Audio API** — `new AudioContext()` | `state: "suspended"` | +| `ctx.resume()` | **약속이 영원히 안 돌아온다** (제스처를 기다리며 매달린다) | +| mp3 를 `decodeAudioData` 로 풀어 `BufferSource` 재생 | `currentTime` **0.000 → 0.000**, 안 돈다 | +| **`speechSynthesis.speak()`** | `error: not-allowed` | +| 마우스만 움직임 / 스크롤만 | `play=0` (`act=false`) | +| **진짜 클릭·탭·키 입력 한 번** | **켜진다** (`act=true`, `play=1`) | + +`dispatchEvent` 와 `el.click()` 이 만든 이벤트는 `isTrusted: false` 로 찍혀 나간다. +크롬의 사용자 활성화 카운터(`navigator.userActivation`)는 `isTrusted: true` 인 입력만 세고, +그게 올라야 소리가 풀린다. + +브라우저에서 **소리를 낼 수 있는 API 는 셋뿐이고 셋 다 같은 게이트 뒤에 있다** — +HTMLMediaElement(`<audio>`) · Web Audio(`AudioContext`) · speechSynthesis. 우회로가 없는 이유다. + +**활성화를 준다**: `mousedown` · `pointerdown` · `pointerup` · `touchend` · `keydown`(Esc 제외) · `click` +**안 준다**: `mousemove` · `pointermove` · `touchmove` · `wheel` · `scroll` + +--- + +## 안 한 것과 그 이유 + +크롬 문서(developer.chrome.com/blog/autoplay)에 예외가 넷 있지만 **전부 손님 쪽 설정이라 +제품에서는 쓸 수 없다.** 한 번 만들었다가 되돌렸다. + +| 예외 | 왜 안 쓰나 | +|---|---| +| PWA 설치본 | **펜션 사이트 보러 온 손님이 앱을 설치할 리 없다.** 매니페스트까지 붙였다가 걷어냈다 | +| MEI (같은 출처에서 7초 이상 여러 번) | 손님이 쌓는 것이라 첫 방문에는 없다 | +| `--autoplay-policy=no-user-gesture-required` | 브라우저를 그 플래그로 띄워야 한다 | +| 기업정책 `AutoplayAllowlist` | 기계 관리자가 넣는 것이다 | + +★ **강제 클릭 코드도 뺐다.** 넣어서 배포까지 해 봤지만 기본 크롬에서 `play=0` 이고, +동작하지 않는 코드가 남으면 다음 사람이 "이미 처리돼 있네" 로 읽는다. + +--- + +## 지금 코드가 하는 일 (`inject.js` `startPlayer`) + +1. 로드 즉시 `play()` 를 건다. (거절되지만, 자동재생이 허락된 환경이면 여기서 켜진다) +2. 거절되면 **첫 사람 손길**을 기다린다. 창 전체에 걸고, 플레이어 자기 단추만 빼고 + 어디를 눌러도 걸린다. + - STRONG(활성화를 주는 입력): 스로틀 없이 매번 시도 + - WEAK(안 주는 입력): 1초에 한 번만 — 초당 수십 번 오는 mousemove 로 단추가 깜빡이는 것을 막는다 +3. 탭이 다시 앞으로 나올 때(`visibilitychange`) 한 번 더 건다. + +### 여기서 밟았던 함정 둘 + +- **가짜 pointerdown 이 대기를 태웠다.** `tameRails()` 가 레일 자동 넘김을 세우려고 레일마다 + `pointerdown` 을 쏘는데(정상 동작), 그게 첫 손길 대기를 먼저 먹었다. 그 뒤로는 손님이 + 아무 데나 눌러도 안 켜졌다. → `onFirstTouch` 가 `event.isTrusted` 를 먼저 본다. +- **스로틀이 클릭을 걸렀다.** 입력을 안 가리고 1초 스로틀을 걸었더니 클릭 직전의 mousemove 가 + 그 1초를 먹어 **정작 활성화를 주는 mousedown 이 걸러졌다**(실측: `act=true` 인데 `play=0`). + → STRONG/WEAK 로 갈라 STRONG 에는 스로틀을 안 건다. + +--- + +## "트리플픽은 되던데?" — 실측 결과 + +**트리플픽도 안 된다. 똑같이 거절당한다.** + +`https://triplepick.o2o.kr/match/MLB_BAL_TOR_20260914?song=BAL` (공유 링크 = 자동 로드 경로)에 +깨끗한 크롬으로 들어가 `HTMLMediaElement.prototype.play` 를 가로채 찍은 값이다. + +``` +[T] play() 거절 NotAllowedError .kr/api/songs/audio/815/0?v=89a0fcd6 +``` + +코드도 우리와 같은 한 줄이다 — `usePlayer.tsx:133` `if (shouldPlay) audio.play().catch(() => {})`. +게스처 감지 코드는 0줄이고, 평소 재생 경로는 `MatchupHUD.tsx:55` 의 **「♪ 응원가 듣기」 버튼**이다. +`playlist.ts:19` 의 `PATH_PLAYLISTS` 는 비어 있어서 경로 진입 자동 로드는 실을 곡도 없다. + +### 그럼 왜 내 브라우저에서는 나나 — MEI + +크롬은 **같은 출처에서 소리를 여러 번 들은 사람에게만** 그 출처의 자동재생을 풀어 준다 +(Media Engagement Index). 조건: 7초 초과 재생 · 음소거 아님 · 탭 활성 · (영상이면) 200x140px 초과. + +트리플픽을 자주 쓰면서 응원가를 여러 번 들었으면 그 브라우저에는 이미 허락이 쌓여 있다. +**그 브라우저에서만** 자동재생된다. 손님의 새 브라우저에서는 안 된다. + +확인: `chrome://media-engagement/` 에서 출처별 점수와 `Has high score` 를 본다. + +★ **같은 일이 이 사이트에도 일어난다.** 목업을 자주 열고 음악을 들으면 그 브라우저는 +`web4ai.o2osolution.ai` 에 허락을 쌓고, 그 뒤로는 새로고침해도 바로 난다. +**대표님 노트북에서 시연하면 그 상태가 된다** — 다만 손님 브라우저는 아니다. + +--- + +## 재보고 싶을 때 — 측정 주의 + +★ **Playwright 로 잴 때 `page.evaluate` 를 쓰면 안 된다.** CDP 가 `userGesture: true` 로 넣어서 +**상태를 읽으려고 부른 그 호출이 브라우저를 "조작됨" 으로 만든다.** 그 뒤 측정은 전부 거짓 +양성이다 — 실제로 이것 때문에 "마우스만 움직여도 켜진다" 는 틀린 결론을 한 번 냈다. + +상태는 `addInitScript` 로 심고 `console` 로만 받는다. + +```js +await page.addInitScript(() => { + setInterval(() => { + const el = document.getElementById('w4d-play'); + console.log('[C] play=' + (el ? el.dataset.playing : '-') + + ' act=' + navigator.userActivation.isActive); + }, 1000); +}); +page.on('console', (m) => { /* 여기서만 읽는다 */ }); +``` + +--- + +## 지금 무엇을 보고 있는지 가리는 법 + +`/s/` 는 `max-age=300, must-revalidate` 다. "고쳤는데 안 된다" 의 상당수가 **캐시된 옛 파일**이었다. +콘솔 첫 줄에 빌드 시각이 찍힌다. + +``` +[w4d] 주입분 로드 · 빌드 2026-09-14T07:12:00+00:00 +``` + +이 시각이 방금 구운 것과 다르면 옛 파일을 보고 있는 것이다. ⌘⇧R. diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index b1d7784..c6cd458 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,28 @@ | `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탭 껍데기 | — | | +| `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` | 섹션 자리가 없다 | +| `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**) · **엽서 쓰기**(독립 섹션, +`festival` 바로 앞). **군산 읽기**(T7)는 2026-09-15 에 렌더러로 들어갔다 — 이제 payload 에 +`local.story.reading` 만 있으면 새 업장에서도 탭이 선다. --- -## 1.3 할 일 여섯 +## 1.3 할 일 여덟 ### T1 — 히어로 캐치프레이즈 @@ -118,7 +121,10 @@ weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈'; }[] }; ``` -2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. +2. **대표 문구(`tagline`)는 고정하고, 순환은 그 아래 줄에서 돈다** (2026-09-11 사장님 지시). + 대표 문구 자리를 갈아 끼우면 숙소의 한 줄이 첫 화면에서 사라진다. 아래 줄은 대표 문구보다 + 한 단 작고 옅게, 높이는 가장 긴 문구에 맞춰 잡는다(히어로가 아래 정렬이라 줄 수가 바뀌면 튄다). + 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. 순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다. 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). 계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울). @@ -161,7 +167,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 +180,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 +188,18 @@ **하루 뼈대** — `@` 는 숙소 ``` -첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ -가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ -쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ -마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ +첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ +가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ +쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ +마지막날(도보) 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ ← 짐을 맡기고 저녁에 찾으러 온다 +마지막날(차) 아침(퇴실·차)@ · 오전 · 점심 · 오후 ← 짐을 싣고 나섰다, 복귀 칸이 없다 ``` +★ **떠나는 날은 둘로 갈린다** (2026-09-14 대표 의견). 차로 온 손님에게 "짐을 맡기고 저녁에 + 찾으러 오세요" 는 **한 번 더 들르라는 말**이다 — 퇴실할 때 실으면 끝날 일이다. 그래서 차 테마의 + 마지막 날은 마지막 정거장에서 끝난다. 짐 보관 동선은 걸어 다니는 손님 것이고 그 테마만 남는다. + 뼈대를 두 벌로 적지 않고 **복귀 칸만 떼어** 만든다(`build_itinerary.py` `CAR_KINDS`). + **완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건. 하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다. @@ -220,9 +232,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 +275,82 @@ --- +### 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()` 를 그대로 옮긴 것이다). + +**할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서 +옆으로 넘겨 가며 볼 수 있는 자리를 만든다. + +1. 이 콘텐츠는 **사장님마다 새로 만드는 게 아니다.** 군산이라는 지역이 공유하는 고정 + 자료다(계절별 축제·주변 명소와 같은 성격) — 사이트를 만들 때마다 AI 가 새로 글을 + 지어내는 게 아니라, 군산에 있는 모든 사이트가 같은 이야기 목록을 나눠 쓴다. +2. 항목마다 출처 링크를 단다. 정확한 문서 주소를 일일이 확인하기 어려우니, 제목으로 + 검색되는 링크(예: 네이버 검색)를 걸어 둔다 — 없는 주소를 지어내 걸지 않는다. +3. 옆으로 넘겨 보는 방식은 **이 사이트가 이미 쓰고 있는 것**을 그대로 쓴다(사진 갤러리· + 추천 일정에 쓰는 그 슬라이드). 새로 스크롤 상자를 만들지 않는다. 한 슬라이드에는 + 이야기 하나만 크게 보여준다 — 여러 개를 한 슬라이드에 욱여넣지 않는다. +4. 한 번 볼 때 전체 이야기를 다 보여주지 않는다. 그중 몇 개만 무작위로 뽑아 보여주고, + 다시 오거나 새로고침하면 다른 조합이 뜬다. 화면 문구에 "전체 몇 개"라고 숫자를 + 박지 않는다 — 실제로 보이는 개수와 어긋나면 안 되기 때문이다. +5. 화면 안에서는 '군산 이야기'(가요 다방·인물 열전 등이 모여 있는 탭 묶음)의 탭 + 하나로 들어간다 — 독립된 섹션으로 따로 빼지 않는다. 다른 탭들처럼 탭 안에도 + 제목을 그대로 보여준다. +6. 이미 다른 자리에 나온 이야기는 다시 넣지 않는다 — 인물·가요·축제·연표·명소·맛집이 + 각자 자리를 갖고 있다. 같은 이야기를 두 번 보여주면 페이지만 길어진다. + +**완료 기준** 갈래를 가리지 않고 훑어볼 수 있고, 이야기마다 출처가 붙어 있고, 넘기는 +방식이 이 사이트에서 이미 쓰는 방식과 같다. + +--- + +### T8 — 엽서 쓰기 + +**증상** '오늘의 엽서'는 미리 써 둔 문구를 보여주기만 했다. 손님이 직접 한마디를 써서 +자기만의 엽서를 만들고, 그걸 저장하거나 공유할 수 있게 바꾼다. + +**이건 AI 가 글을 짓는 기능이 아니다.** 새 데이터 형식이나 문구를 설계할 필요 없이, +이미 만들어 확인까지 끝낸 화면 기능 하나를 그대로 옮겨 붙이면 되는 일이다. + +1. 숙소 사진(가진 사진 전체를 보여주고 손님이 그중 하나를 고르게 한다) + 손님이 + 직접 쓴 한마디 + 우표·소인 모양을 합쳐서 한 장의 카드 이미지로 만든다. +2. 문구는 최대 네 줄까지만 쓸 수 있게 막는다. 줄이 넘어가면 자동으로 다음 줄로 + 넘어가는데, 띄어쓰기 없이 길게 이어 쓴 글자도 카드 밖으로 삐져나오지 않고 + 글자 단위로 잘 접혀야 한다. +3. 완성한 카드는 사진으로 저장하거나 공유할 수 있다. 공유할 때는 카드 이미지와 + 함께, 지금 보고 있는 이 사이트 페이지로 가는 링크를 같이 보낸다. 손님이 쓴 + 문구는 이미 카드 그림 안에 박혀 있으니, 공유 문구 자리에 그 글을 따로 한 번 더 + 적지 않는다 — 같은 말이 두 번 보이면 안 된다. 서버에 새로 올리는 기능을 만들 + 필요 없이 휴대폰·브라우저가 원래 갖고 있는 공유 기능을 그대로 쓰고, 그래서 이 + 기능은 서버를 한 번도 부르지 않는다. +4. 공유 버튼이나 안내 문구에 특정 메신저 이름을 못 박지 않는다 — 기기에 따라 + 실제로 뜨는 공유 대상이 다르다(휴대폰은 설치된 메신저가 뜨지만, 컴퓨터 특히 + 맥은 카카오톡이 그 목록에 안 뜬다). 버튼 문구는 손끝으로 조작하는 기기에서만 + "카카오톡 등으로 공유"라고 쓰고, 그 외에는 그냥 "공유하기"라고 쓴다. +5. 상호명을 코드 안에 고정으로 박아 넣지 않는다 — 이 기능은 다른 숙소나 카페에서도 + 그대로 가져다 써야 한다. +6. 화면 위치는 "계절별 축제" 섹션 바로 앞에 독립된 섹션으로 둔다. + +**완료 기준** 손님이 사진 고르고 글 쓰고 저장·공유하는 것까지 한 화면에서 끝난다. +새 서버 기능이나 AI 프롬프트가 필요 없다. + +--- + ## 1.4 프롬프트 전문 **프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.** @@ -307,7 +404,7 @@ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치 창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분} [해야 할 일] -[업소] 의 히어로 문구를 100개 만든다. 상호 바로 아래에 한 줄로 뜨고 7초마다 바뀐다. +[업소] 의 히어로 문구를 100개 만든다. 상호와 대표 문구 아래에 한 줄로 뜨고 7초마다 바뀐다. 네 종류로 나눠 만든다. · 일반 40개 — 언제 나와도 되는 문구 @@ -552,9 +649,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 이그노어) @@ -596,22 +695,74 @@ node audit-all.mjs 그 기계에서 구운 번들 해시와 **무관하게** 뜬다. (2026-09-11 이전 판은 `/assets/…` 를 가리켜 다른 기계에서 흰 화면이었다.) +### 점검 도구는 어디서 도나 — 개발 PC에서, 주소를 받아서 + +`audit-all.mjs` · `rails-test.mjs` 는 **Playwright 로 진짜 크롬을 띄운다.** 그래서 + +- **킹서버에서는 못 돈다.** 그 서버에는 node 도 node_modules 도 없다(브라우저는 더더욱). +- 대신 **개발 PC에서 주소를 주고 돌린다.** 대상이 로컬이든 킹서버든 명령은 같다. + +```bash +npm install # 레포 루트에서 한 번 (playwright 는 devDependencies) +node audit-all.mjs # http://localhost/s/stay +node audit-all.mjs https://web4ai.o2osolution.ai/s/stay +``` + +★ 도커에 넣지 않는다. 점검은 **배포물이 아니라 개발 도구**다 — 운영 이미지에 브라우저(300MB+)를 + 넣으면 서버가 그만큼 무거워지는데, 정작 점검은 사람이 볼 때만 돌린다. + +### 렌더러를 다시 구운 뒤 (번들만 갈아 끼운다) + +목업은 **다시 만들지 않는다.** `vendor/` 안의 css·js 한 벌만 새 번들로 바꾸고 주소를 다시 박는다. + +```bash +C=o2o-web4ai-solution-prerender +CSS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.css$" | head -1') +JS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.js$" | head -1') +mkdir -p vendor/retired && mv vendor/index-*.css vendor/index-*.js vendor/retired/ +docker cp $C:/app/solution/site/out/assets/$CSS vendor/ +docker cp $C:/app/solution/site/out/assets/$JS vendor/ +python3 patch_stay.py # vendor 안의 한 벌을 찾아 주소만 박는다 +``` + +★ `vendor/` 에는 css·js 가 **각각 한 개만** 있어야 한다(`patch_stay.py` 가 assert 로 막는다). +★ 번들이 낡으면 **payload 에 값이 있어도 화면이 빈다.** 소스에 섹션이 있다고 화면에 있는 게 아니다 — + 실측(2026-09-14): 머지 뒤 이미지를 안 구워서 `StorySection`·`DailySection` 이 번들에 없었다. + +### 점검 기준이 결정을 따라가야 한다 (2026-09-14) + +화면과 `audit-all.mjs` 가 갈리면 점검이 거짓말을 한다. 그날 바뀐 결정 셋: + +| 자리 | 결정 | 점검 | +|---|---|---| +| 오늘의 한 장 | 탭을 세우지 않는다(사장님: "주석처리해주쇼") — `patch_stay.py` `HIDE_TABS` | `오늘의 한 장 숨김` — 탭이 **없어야** 통과 | +| 엽서 | 도시 엽서 4장을 빼고 손님이 쓰는 엽서로(`inject.js` `postcardMaker`) | `엽서 쓰기` — `#w4d-pm-section` 유무 | +| 군산 읽기 | 34가지 중 **무작위 5~6꼭지** 카로셀. 신문 조판(제호·면·격자)은 걷어냈다 | `군산 읽기 화면` — 5~6꼭지 | + ### 내용을 바꾼 뒤 (전체 굽기) ```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 +796,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 +829,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 +892,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 +919,96 @@ 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 가 그린 `<p>` 를 찾아 단어 단위로 갈아 끼운다 → T1 -2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4 -3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 -4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 -5. **카로셀 제어** — 아래 +1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 +2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. + ★ **자동재생은 껐다** (2026-09-15 대표: "자동재생 안 되고 클릭하고 나서 되니까 사람들이 + 에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다(09-11 빼 → 09-14 켜 → 09-15 끔). 이번 이유는 + 앞의 둘과 다르다 — 정책을 못 이겨서가 아니라, **정책에 걸린 그 모습이 고장으로 읽혀서**다. + `inject.js` 의 `autoplay()` 는 남아 있고 호출만 뺐다(`void autoplay;`) → T4 +3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는 + 시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다. + '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 + 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 +1044,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" ★ **React 가 다시 그리면 사라진다.** 그래서 - 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다 - 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다 -- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다** +- 캐치프레이즈 줄은 노드를 들고 있지 않고 **매 순환마다 붙어 있는지 보고, 없으면 대표 문구를 다시 찾아 새로 붙인다** ★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `<img>` 가 140장이라 `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. @@ -787,6 +1066,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 +1087,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 +1149,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 +1170,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 +1184,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 +1229,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`** | +| 날씨 문구를 만드는 경로 | 2026-09-15: 제품에 공통 안내 50개를 읽어 `noteSets`·`tempNoteSets`로 싣고 20초마다 순환하는 경로를 추가했다([WEATHER.md](../../../../docs/WEATHER.md)). 이 목업의 군산 전용 문구는 그대로다. 지역별 장소 추천을 자동 생성하는 잡은 아직 없다 | +| 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 | | 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 | diff --git a/solution/site/scripts/mockup/SCHEDULE.md b/solution/site/scripts/mockup/SCHEDULE.md index 472cd45..dd88789 100644 --- a/solution/site/scripts/mockup/SCHEDULE.md +++ b/solution/site/scripts/mockup/SCHEDULE.md @@ -1,14 +1,14 @@ # `/s/stay` 여행 일정 전수 검수표 -생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 304정거장 +생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 290정거장 표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다). -규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고, +규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고, 이 표는 그 규칙이 **안 보는 논리**를 따로 센다. ## 결과 — 21/21 검수완료 -**전부 통과.** 12개 항목 위반 0건. +**전부 통과.** 13개 항목 위반 0건. | 코드 | 검수 항목 | |---|---| @@ -20,10 +20,11 @@ | L5 | 바깥 정거장 체류 15분 이상 | | L6 | 하루가 14시간을 넘지 않는다 | | L7 | 입실 15:00 이후 · 퇴실 11:00 전 | -| L8 | 하루의 첫 칸·마지막 칸이 숙소 | +| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) | | L9 | 같은 자리가 연달아 서지 않는다 | | L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 | | L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 | +| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 | --- @@ -44,14 +45,13 @@ | 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 | | 6 | 19:41–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 | -**둘째 날** 08:20–16:36 (총 8시간 16분 · 이동 176분 · 정거장 4) +**둘째 날** 08:20–15:49 (총 7시간 29분 · 이동 169분 · 정거장 3) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| | 1 | 08:20–09:35 | 아침 | 스테이 머뭄 | –분 | 75분 | | 2 | 10:59–13:29 | 오전 | 고군산군도 선유도 | 84분 | 150분 | | 3 | 14:54–15:49 | 늦은 점심 | 국제반점 | 85분 | 55분 | -| 4 | 15:56–16:36 | 마무리 | 스테이 머뭄 | 7분 | 40분 | --- @@ -101,7 +101,7 @@ | 5 | 19:33–20:12 | 밤 산책 | 군산 내항 | 5분 | 39분 | | 6 | 20:28–20:57 | 머뭄으로 | 스테이 머뭄 | 16분 | 29분 | -**둘째 날** 09:40–15:36 (총 5시간 56분 · 이동 52분 · 정거장 5) +**둘째 날** 09:40–14:31 (총 4시간 51분 · 이동 33분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -109,7 +109,6 @@ | 2 | 11:10–12:02 | 오전 | 군산근대건축관 | 10분 | 52분 | | 3 | 12:10–13:07 | 점심 | 일흥옥 | 8분 | 57분 | | 4 | 13:22–14:31 | 오후 | 째보선창 근처 카페 | 15분 | 69분 | -| 5 | 14:50–15:36 | 마무리 | 스테이 머뭄 | 19분 | 46분 | --- @@ -130,7 +129,7 @@ | 5 | 18:52–19:30 | 밤 산책 | 군산 내항 | 11분 | 38분 | | 6 | 19:46–20:14 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 | -**둘째 날** 08:50–13:35 (총 4시간 45분 · 이동 37분 · 정거장 5) +**둘째 날** 08:50–12:49 (총 3시간 59분 · 이동 29분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -138,7 +137,6 @@ | 2 | 10:12–11:22 | 오전 | 군산근대미술관 | 9분 | 70분 | | 3 | 11:30–12:18 | 점심 | 일흥옥 | 8분 | 48분 | | 4 | 12:30–12:49 | 오후 | 해망굴 | 12분 | 19분 | -| 5 | 12:57–13:35 | 마무리 | 스테이 머뭄 | 8분 | 38분 | --- @@ -188,7 +186,7 @@ | 5 | 18:42–19:20 | 밤 산책 | 군산 내항 | 9분 | 38분 | | 6 | 19:36–20:04 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 | -**둘째 날** 08:30–14:06 (총 5시간 36분 · 이동 36분 · 정거장 5) +**둘째 날** 08:30–13:21 (총 4시간 51분 · 이동 29분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -196,7 +194,6 @@ | 2 | 09:59–11:19 | 오전 | 경암동 철길마을 | 12분 | 80분 | | 3 | 11:30–12:18 | 점심 | 일흥옥 | 11분 | 48분 | | 4 | 12:24–13:21 | 오후 | 군산 시간여행마을 | 6분 | 57분 | -| 5 | 13:28–14:06 | 마무리 | 스테이 머뭄 | 7분 | 38분 | --- @@ -246,7 +243,7 @@ | 5 | 18:46–19:30 | 밤 산책 | 군산 내항 | 11분 | 44분 | | 6 | 19:46–20:19 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 | -**둘째 날** 09:00–14:40 (총 5시간 40분 · 이동 60분 · 정거장 5) +**둘째 날** 09:00–13:43 (총 4시간 43분 · 이동 47분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -254,7 +251,6 @@ | 2 | 10:40–11:46 | 오전 | 채만식문학관 | 18분 | 66분 | | 3 | 12:03–12:58 | 점심 | 일흥옥 | 17분 | 55분 | | 4 | 13:10–13:43 | 오후 | 구 군산세관 본관 | 12분 | 33분 | -| 5 | 13:56–14:40 | 마무리 | 스테이 머뭄 | 13분 | 44분 | --- @@ -275,7 +271,7 @@ | 5 | 18:30–19:04 | 밤 산책 | 군산 내항 | 13분 | 34분 | | 6 | 19:20–19:46 | 머뭄으로 | 스테이 머뭄 | 16분 | 26분 | -**둘째 날** 10:10–14:10 (총 4시간 0분 · 이동 42분 · 정거장 5) +**둘째 날** 10:10–13:27 (총 3시간 17분 · 이동 33분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -283,7 +279,6 @@ | 2 | 11:12–11:54 | 오전 | 경암동 철길마을 | 12분 | 42분 | | 3 | 12:05–12:35 | 점심 | 이성당 | 11분 | 30분 | | 4 | 12:45–13:27 | 오후 | 월명호수 | 10분 | 42분 | -| 5 | 13:36–14:10 | 마무리 | 스테이 머뭄 | 9분 | 34분 | --- @@ -301,7 +296,7 @@ | 2 | 19:05–20:20 | 저녁 | 명월갈비 | 5분 | 75분 | | 3 | 20:25–20:55 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 | -**둘째 날** 08:40–14:39 (총 5시간 59분 · 이동 39분 · 정거장 6) +**둘째 날** 08:40–13:51 (총 5시간 11분 · 이동 31분 · 정거장 5) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -310,7 +305,6 @@ | 3 | 11:42–12:32 | 점심 | 한일옥 | 6분 | 50분 | | 4 | 12:36–13:21 | 오후 | 군산근대건축관 | 4분 | 45분 | | 5 | 13:31–13:51 | 오후 | 해망굴 | 10분 | 20분 | -| 6 | 13:59–14:39 | 마무리 | 스테이 머뭄 | 8분 | 40분 | --- @@ -382,7 +376,7 @@ | 6 | 18:47–20:02 | 저녁 | 명월갈비 | 5분 | 75분 | | 7 | 20:07–20:37 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 | -**셋째 날** 09:20–13:46 (총 4시간 26분 · 이동 19분 · 정거장 5) +**셋째 날** 09:20–12:59 (총 3시간 39분 · 이동 12분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -390,7 +384,6 @@ | 2 | 10:41–11:26 | 오전 | 초원사진관 | 4분 | 45분 | | 3 | 11:30–12:20 | 점심 | 일흥옥 | 4분 | 50분 | | 4 | 12:24–12:59 | 오후 | 이성당 | 4분 | 35분 | -| 5 | 13:06–13:46 | 마무리 | 스테이 머뭄 | 7분 | 40분 | --- @@ -466,7 +459,7 @@ | 7 | 17:30–18:50 | 저녁 | 빈해원 | 11분 | 80분 | | 8 | 19:01–19:35 | 머뭄으로 | 스테이 머뭄 | 11분 | 34분 | -**셋째 날** 08:40–15:18 (총 6시간 38분 · 이동 32분 · 정거장 5) +**셋째 날** 08:40–14:21 (총 5시간 41분 · 이동 21분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -474,7 +467,6 @@ | 2 | 10:30–11:20 | 오전 | 동국사 | 6분 | 50분 | | 3 | 11:30–12:33 | 점심 | 국제반점 | 10분 | 63분 | | 4 | 12:38–14:21 | 오후 | 군산근대역사박물관 | 5분 | 103분 | -| 5 | 14:32–15:18 | 마무리 | 스테이 머뭄 | 11분 | 46분 | --- @@ -505,7 +497,7 @@ | 4 | 17:30–18:25 | 저녁 | 일흥옥 | 5분 | 55분 | | 5 | 18:30–19:03 | 머뭄으로 | 스테이 머뭄 | 5분 | 33분 | -**셋째 날** 09:00–14:34 (총 5시간 34분 · 이동 27분 · 정거장 5) +**셋째 날** 09:00–13:33 (총 4시간 33분 · 이동 18분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -513,7 +505,6 @@ | 2 | 10:44–11:30 | 오전 | 동국사 | 6분 | 46분 | | 3 | 11:33–12:19 | 점심 | 물밀소 | 3분 | 46분 | | 4 | 12:28–13:33 | 오후 | 월명호수 | 9분 | 65분 | -| 5 | 13:42–14:34 | 마무리 | 스테이 머뭄 | 9분 | 52분 | --- @@ -547,7 +538,7 @@ | 7 | 17:30–18:15 | 저녁 | 국제반점 | 7분 | 45분 | | 8 | 18:22–18:46 | 머뭄으로 | 스테이 머뭄 | 7분 | 24분 | -**셋째 날** 08:50–13:35 (총 4시간 45분 · 이동 29분 · 정거장 5) +**셋째 날** 08:50–12:54 (총 4시간 4분 · 이동 20분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -555,7 +546,6 @@ | 2 | 10:46–11:26 | 오전 | 우체통거리 | 8분 | 40분 | | 3 | 11:30–12:10 | 점심 | 일흥옥 | 4분 | 40분 | | 4 | 12:18–12:54 | 오후 | 군산근대미술관 | 8분 | 36분 | -| 5 | 13:03–13:35 | 마무리 | 스테이 머뭄 | 9분 | 32분 | --- @@ -589,7 +579,7 @@ | 7 | 17:37–19:07 | 저녁 | 명월갈비 | 5분 | 90분 | | 8 | 19:12–19:48 | 머뭄으로 | 스테이 머뭄 | 5분 | 36분 | -**셋째 날** 09:40–15:27 (총 5시간 47분 · 이동 45분 · 정거장 5) +**셋째 날** 09:40–14:32 (총 4시간 52분 · 이동 38분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -597,7 +587,6 @@ | 2 | 11:18–12:30 | 오전 | 채만식문학관 | 18분 | 72분 | | 3 | 12:47–13:47 | 점심 | 한일옥 | 17분 | 60분 | | 4 | 13:50–14:32 | 오후 | 이성당 | 3분 | 42분 | -| 5 | 14:39–15:27 | 마무리 | 스테이 머뭄 | 7분 | 48분 | --- @@ -671,7 +660,7 @@ | 6 | 18:37–19:48 | 저녁 | 명월갈비 | 5분 | 71분 | | 7 | 19:53–20:21 | 머뭄으로 | 스테이 머뭄 | 5분 | 28분 | -**셋째 날** 09:40–14:20 (총 4시간 40분 · 이동 42분 · 정거장 5) +**셋째 날** 09:40–13:30 (총 3시간 50분 · 이동 30분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -679,7 +668,6 @@ | 2 | 11:01–11:44 | 오전 | 군산근대건축관 | 10분 | 43분 | | 3 | 11:53–12:31 | 점심 | 만남스넥 | 9분 | 38분 | | 4 | 12:42–13:30 | 오후 | 경암동 철길마을 | 11분 | 48분 | -| 5 | 13:42–14:20 | 마무리 | 스테이 머뭄 | 12분 | 38분 | --- @@ -752,7 +740,7 @@ | 7 | 17:30–18:38 | 저녁 | 명월갈비 | 5분 | 68분 | | 8 | 18:43–19:10 | 머뭄으로 | 스테이 머뭄 | 5분 | 27분 | -**셋째 날** 09:30–13:48 (총 4시간 18분 · 이동 42분 · 정거장 5) +**셋째 날** 09:30–13:03 (총 3시간 33분 · 이동 33분 · 정거장 4) | | 시각 | 자리 | 장소 | 이동 | 체류 | |---|---|---|---|---|---| @@ -760,5 +748,4 @@ | 2 | 10:51–11:18 | 오전 | 구 군산세관 본관 | 13분 | 27분 | | 3 | 11:30–12:15 | 점심 | 일흥옥 | 12분 | 45분 | | 4 | 12:23–13:03 | 오후 | 군산근대미술관 | 8분 | 40분 | -| 5 | 13:12–13:48 | 마무리 | 스테이 머뭄 | 9분 | 36분 | 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/audit-all.mjs b/solution/site/scripts/mockup/audit-all.mjs index be78713..9c0b475 100644 --- a/solution/site/scripts/mockup/audit-all.mjs +++ b/solution/site/scripts/mockup/audit-all.mjs @@ -25,12 +25,48 @@ const d = await page.evaluate(() => { catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length), themes: itin.length, firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')), - lastIsStay: days.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄')), + // 떠나는 날은 갈린다 — 차로 온 손님은 짐을 싣고 나서므로 복귀 칸이 없다(2026-09-14 대표 의견). + lastIsStay: itin.every((i) => i.days.slice(0, -1) + .every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄'))), + depart: (() => { + const tail = (i) => i.days[i.days.length - 1]; + const car = itin.filter((i) => !tail(i).stops[tail(i).stops.length - 1].name.endsWith('스테이 머뭄')); + const walk = itin.filter((i) => !car.includes(i)); + return { + car: car.length, walk: walk.length, + car1: car.filter((i) => i.duration === '1박 2일').length, + car2: car.filter((i) => i.duration === '2박 3일').length, + // 퇴실 칸의 문구가 동선과 같은 말을 하는가 — 차인데 "맡겨 두고" 면 손님이 한 번 더 들른다 + carNote: car.every((i) => (tail(i).stops[0].note || '').includes('차에 싣고')), + walkNote: walk.every((i) => (tail(i).stops[0].note || '').includes('맡겨 두고')), + walkEnd: walk.every((i) => tail(i).stops[tail(i).stops.length - 1].name.startsWith('마무리')), + }; + })(), consecutive: days.filter((day) => day.stops.some((s, i) => i && s.name.split(' · ').pop() === day.stops[i - 1].name.split(' · ').pop())).length, hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note), stopImages: stops.filter((s) => s.imageUrl).length, restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).length, endTimes: (() => { const t = days.map((day) => { let c = +day.startTime.slice(0, 2) * 60 + +day.startTime.slice(3); day.stops.forEach((s) => { c += s.moveMinutes + s.minutes; }); return c; }); return [Math.min(...t), Math.max(...t)]; })(), + daily: sec('daily').items.length, + reading: (() => { const e = sec('reading'); return { + items: e.items.length, + groups: (e.groups || []).length, + sourced: e.items.filter((x) => x.source && x.source.url).length, + }; })(), + domReadCards: document.querySelectorAll('#w4d-reading-panel .w4d-read-card').length, + domReadGroups: document.querySelectorAll('#w4d-reading-panel .w4d-read-group').length, + domReadSrc: document.querySelectorAll('#w4d-reading-panel .w4d-read-src a').length, + // 레일이 아니어야 한다 — 가로로 밀어야 보이면 '펼쳐 둔다' 가 아니다. + // 신문 조판이라 단(column)으로 흐른다(2026-09-14 대표: "신문처럼"). + readCols: (() => { const el = document.querySelector('#w4d-reading-panel .w4d-read-grid'); + if (!el) return null; + return {cols: getComputedStyle(el).columnCount, overflow: el.scrollWidth - el.clientWidth}; })(), + readPaper: !!document.querySelector('#w4d-reading-panel .w4d-read-head h2') + && !!document.querySelector('#w4d-reading-panel .w4d-read-dateline'), + dailyCats: [...new Set(sec('daily').items.map((x) => x.category))].sort(), + dailySourced: sec('daily').items.filter((x) => x.source && x.source.url).length, + domDailyPages: document.querySelectorAll('#daily article').length, + storyTabs: [...document.querySelectorAll('#story [role="tab"]')].map((t) => t.textContent.trim()), songs: sec('songs').items.length, ownInSongs: sec('songs').items.filter((s) => s.own).length, songLinks: sec('songs').items.filter((s) => s.listenUrl).length, @@ -55,19 +91,61 @@ const d = await page.evaluate(() => { roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length, roomMirror: P.units.flatMap((u) => u.mediaIds).length, tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(), + sub: document.getElementById('w4d-sub')?.textContent.trim(), + subIsCatch: (() => { const t = document.getElementById('w4d-sub')?.textContent.trim(); return !!t && (P.narrative.catchphrases?.items || []).some((x) => x.text.replace(/\s+/g, ' ').trim() === t); })(), + postcardMaker: !!document.getElementById('w4d-pm-section'), + restaurants: P.local.restaurants.length, + // 걸어서 10분(800m) 넘는 맛집 — 없으면 '10분 이상' 탭에서 맛집 칸이 빈다(2026-09-11 사장님 지적) + restPhotos: P.local.restaurants.filter((r) => r.imageUrl).length, + restFar: P.local.restaurants.filter((r) => { const m = /^([\d.]+)\s*(km|m)$/i.exec(r.distanceText || ''); return m && Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) > 800; }).length, }; }); ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`); -ok('캐치프레이즈 순환', d.tagline && d.tagline !== '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `현재 "${d.tagline}"`); +// 대표 문구는 고정, 순환은 그 아래 줄 (2026-09-11 사장님 지시) +ok('대표 문구 고정', d.tagline === '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `"${d.tagline}"`); +ok('아래 줄에 캐치프레이즈', d.subIsCatch, `"${d.sub}"`); +const sub2 = await page.waitForTimeout(7500).then(() => page.evaluate(() => document.getElementById('w4d-sub')?.textContent.trim())); +ok('아래 줄 순환', !!sub2 && sub2 !== d.sub, `→ "${sub2}"`); +/* 자동재생 (2026-09-14 대표 지시). 브라우저는 조작 없는 소리 재생을 막는다 — 정책이라 못 넘는다. + 그래서 **첫 손길에 켜지는가**를 센다. 막지 않는 브라우저면 그 전에 이미 켜져 있다. */ +const autoAtLoad = await page.evaluate(() => document.getElementById('w4d-play')?.dataset.playing); +await page.mouse.wheel(0, 300); +await page.waitForTimeout(2500); +const autoAfter = await page.evaluate(() => ({ + btn: document.getElementById('w4d-play')?.dataset.playing, + time: document.querySelector('.w4d-item[data-playing="1"] .w4d-time')?.textContent, +})); +ok('자동재생', autoAfter.btn === '1', + `불러온 직후 ${autoAtLoad === '1' ? '바로 재생' : '브라우저가 막음'} → 첫 손길에 ${autoAfter.time || '안 켜짐'}`); +ok('주변 맛집', d.restaurants >= 30 && d.restFar > 0, `${d.restaurants}곳 · 걸어서 10분 넘는 곳 ${d.restFar}`); +ok('맛집 사진', d.restPhotos === d.restaurants, `${d.restPhotos}/${d.restaurants}`); ok('일정 테마', d.themes === 21, `${d.themes}개`); ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남'); -ok('마지막 = 스테이 머뭄', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남'); +ok('마지막 = 머뭄 (떠나는 날 빼고)', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남'); +ok('떠나는 날 복귀 없음 = 차 테마', d.depart.car === 14 && d.depart.car1 === 7 && d.depart.car2 === 7, + `차 ${d.depart.car}(1박${d.depart.car1}·2박${d.depart.car2}) · 도보 ${d.depart.walk}`); +ok('떠나는 날 퇴실 문구', d.depart.carNote && d.depart.walkNote && d.depart.walkEnd, + d.depart.carNote && d.depart.walkNote && d.depart.walkEnd ? '차=싣고 · 도보=맡기고 찾으러' : '어긋남'); ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`); ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐'); ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`); ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`); ok('종료 시각 분산', d.endTimes[1] - d.endTimes[0] > 240, `${(d.endTimes[0] / 60 | 0)}:${String(d.endTimes[0] % 60).padStart(2, '0')} ~ ${(d.endTimes[1] / 60 | 0)}:${String(d.endTimes[1] % 60).padStart(2, '0')}`); +/* 군산 읽기 — 34가지 중 **무작위 5~6꼭지만** 카로셀로 (2026-09-14 대표: "그냥 5-6개 정도만 + 보여주는거 어때? 랜덤으로"). 신문 조판(제호·면·격자)은 같은 날 대표가 걷어냈다("문학면? + 말이 안 되는거 같네") — 그 항목을 여기서도 뺀다. 화면과 점검이 갈리면 점검이 거짓말을 한다. */ +ok('군산 읽기 재료', d.reading.items >= 30, `payload ${d.reading.items}가지`); +ok('군산 읽기 화면', d.domReadCards >= 5 && d.domReadCards <= 6, + `${d.domReadCards}꼭지 (매번 무작위 5~6)`); +ok('군산 읽기 출처', d.reading.sourced === d.reading.items && d.domReadSrc === d.domReadCards, + `payload ${d.reading.sourced}/${d.reading.items} · 화면 ${d.domReadSrc}/${d.domReadCards}`); +/* 오늘의 한 장 — 탭을 **세우지 않는 것**이 정상이다 (2026-09-14 사장님: "오늘의 한장은 + 주석처리해주쇼", patch_stay.py HIDE_TABS). 값은 stay-payload-new.json 에 그대로 있다. */ +ok('오늘의 한 장 숨김', !d.storyTabs.includes('오늘의 한 장') && d.daily === 0, + `탭 없음 · payload ${d.daily}장`); +/* 엽서 — 도시 엽서 4장은 뺐고(2026-09-14 대표), 그 자리는 손님이 직접 쓰는 엽서다. */ +ok('엽서 쓰기', d.postcardMaker, d.postcardMaker ? '있음' : '없음'); ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`); ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`); ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`); @@ -78,7 +156,6 @@ ok('인물 사진', d.peoplePhotos >= 18 && d.domPeopleImg === d.peoplePhotos, ` ok('사진 있는 사람이 앞', d.photoFirst, d.photoFirst ? `앞 ${d.peoplePhotos}명` : '흩어져 있다'); ok('사진 저작자 표시', d.domCredit === d.peopleCredits, `${d.domCredit}/${d.peopleCredits} (미러 ${d.peoplePhotos - d.peopleCredits}장 제외)`); ok('일정 지도 링크', d.domItinLinks > 150, `${d.domItinLinks}개`); -ok('엽서 지도 링크', d.domPostcardLinks === 4, `${d.domPostcardLinks}개`); ok('플레이어 단추 2개', d.miniBtns.length === 2 && !d.miniBtns.includes('w4d-next'), d.miniBtns.join(',')); ok('카세트 아이콘', d.cassette, d.cassette ? '있음' : '없음'); ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}곡`); diff --git a/solution/site/scripts/mockup/audit_schedule.py b/solution/site/scripts/mockup/audit_schedule.py index 00d0a39..6e6b20e 100644 --- a/solution/site/scripts/mockup/audit_schedule.py +++ b/solution/site/scripts/mockup/audit_schedule.py @@ -1,4 +1,4 @@ -"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 14종이 **안 보는** 논리까지 검수한다. +"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 17종이 **안 보는** 논리까지 검수한다. ★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다. 이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼 @@ -95,10 +95,19 @@ def check(item): if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60: bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}")) # L8 시작·끝이 숙소인가 + # ★ 떠나는 날은 갈린다 (2026-09-14 대표 의견). 차로 온 손님은 퇴실할 때 짐을 싣고 + # 나서므로 복귀 칸이 없다 — 그 하루는 마지막 정거장에서 끝나는 것이 맞다. + # 판별은 **퇴실 칸의 문구**로 한다(payload 에 남는 값이라 표와 화면이 같은 것을 본다). + car_out = is_last and "차에 싣고" in (day["stops"][0].get("note") or "") if rows[0]["place"] != STAY: bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}")) - if rows[-1]["place"] != STAY: + if rows[-1]["place"] != STAY and not car_out: bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}")) + # L12 떠나는 날의 끝맺음이 퇴실 문구와 같은 말을 하는가 + if car_out and rows[-1]["place"] == STAY: + bad.append(("L12", f"{label}: 짐을 차에 싣고 나섰는데 숙소로 돌아온다")) + if is_last and not car_out and rows[-1]["slot"] != "마무리": + bad.append(("L12", f"{label}: 짐을 맡겨 두고 나섰는데 찾으러 돌아오지 않는다")) # L9 같은 자리 연속 for i in range(1, len(rows)): if rows[i]["place"] == rows[i - 1]["place"]: @@ -124,7 +133,7 @@ lines = ["# `/s/stay` 여행 일정 전수 검수표", "", f"{sum(len(i['days']) for i in items)}일 · " f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "", "표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).", - "규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,", + "규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,", "이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "", "| 코드 | 검수 항목 |", "|---|---|", "| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |", @@ -135,10 +144,11 @@ lines = ["# `/s/stay` 여행 일정 전수 검수표", "", "| L5 | 바깥 정거장 체류 15분 이상 |", "| L6 | 하루가 14시간을 넘지 않는다 |", "| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |", - "| L8 | 하루의 첫 칸·마지막 칸이 숙소 |", + "| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |", "| L9 | 같은 자리가 연달아 서지 않는다 |", "| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |", - "| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", ""] + "| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", + "| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |", ""] fails = [] for item in items: @@ -169,7 +179,7 @@ if fails: head += ["| 테마 | 걸린 항목 |", "|---|---|"] head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails] else: - head += ["**전부 통과.** 12개 항목 위반 0건."] + head += ["**전부 통과.** 13개 항목 위반 0건."] head.append("") lines = lines[:8] + head + lines[8:] 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_itinerary.py b/solution/site/scripts/mockup/build_itinerary.py index 25d61b9..dfe0b86 100644 --- a/solution/site/scripts/mockup/build_itinerary.py +++ b/solution/site/scripts/mockup/build_itinerary.py @@ -122,8 +122,14 @@ STAY_ACTION = { # 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아** # 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다. # 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다. + # ★ 퇴실 뒤가 **차로 온 손님과 걸어 온 손님이 갈리는 자리**다 (2026-09-14 대표 의견). + # 차로 온 손님은 짐을 싣고 그대로 나선다 — 짐을 맡겨 두고 저녁에 숙소로 되돌아오는 동선은 + # 차가 있는 사람에게는 한 번 더 들르라는 말이 된다. 그래서 그 하루는 **마지막 정거장에서 끝난다**. + # 짐 보관은 걸어 다니는 손님에게 필요한 것이고, 그 테마만 저녁에 찾으러 돌아온다. "아침(퇴실)": ((45, 75, 115), "아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."), + "아침(퇴실·차)": ((45, 75, 115), + "아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다."), "머뭄에서 쉬는 날": ((120, 210, 360), "오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."), # ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?"). @@ -203,6 +209,17 @@ SKELETON = { } # 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time. CHECKOUT_KINDS = {"last", "last_plus", "last_island"} +# ★ 차로 온 손님의 마지막 날은 **복귀 칸이 없다** (2026-09-14 대표 의견). +# 퇴실할 때 짐을 차에 싣고 나서므로 숙소로 되돌아올 일이 없다 — 마지막 정거장이 하루의 끝이다. +# 뼈대를 따로 적지 않고 **복귀 칸만 떼어** 만든다. 그래야 도보 뼈대를 고칠 때 차 뼈대가 같이 따라온다 +# (두 벌로 두면 한쪽만 고치고 넘어가는 자리가 생긴다 — 이 파일이 실제로 그래서 틀린 적이 있다). +CAR_KINDS = set() +for _base in ("last", "last_plus", "last_island"): + _car = _base + "_car" + assert SKELETON[_base][-1] == "마무리@" + SKELETON[_car] = SKELETON[_base][:-1] + CHECKOUT_KINDS.add(_car) + CAR_KINDS.add(_car) # 하루의 시작 = **머뭄에서 아침을 여는 시각**이다(첫날만 입실 시각). START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island": "07:40", "mid_late": "09:40", "mid_rest": "10:00", "last": "08:10", "last_plus": "07:50", @@ -211,6 +228,10 @@ START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island": FLOOR = {"first": "15:00", "first_late": "18:00", "mid": "07:20", "mid_island": "07:00", "mid_late": "09:00", "mid_rest": "09:30", "last": "07:20", "last_plus": "07:20", "last_island": "07:20"} +for _car in CAR_KINDS: + START[_car] = START[_car[:-4]] + FLOOR[_car] = FLOOR[_car[:-4]] + DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"] DURATION = {2: "1박 2일", 3: "2박 3일"} @@ -221,9 +242,9 @@ def _minutes(text: str) -> int: def _action(slot: str, kind: str) -> tuple: - """머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸한다.""" + """머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸하고, 차 테마는 문구가 다르다.""" if slot == "아침" and kind in CHECKOUT_KINDS: - return STAY_ACTION["아침(퇴실)"] + return STAY_ACTION["아침(퇴실·차)" if kind in CAR_KINDS else "아침(퇴실)"] return STAY_ACTION[slot] @@ -394,7 +415,7 @@ THEMES = [ ("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님", "첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.", [("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]), - ("last_island", ["고군산군도 선유도", "국제반점"])]), + ("last_island_car", ["고군산군도 선유도", "국제반점"])]), ("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님", "모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.", [("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]), @@ -402,11 +423,11 @@ THEMES = [ ("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님", "실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.", [("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]), - ("last", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]), + ("last_car", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]), ("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님", "1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.", [("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]), - ("last", ["군산근대미술관", "일흥옥", "해망굴"])]), + ("last_car", ["군산근대미술관", "일흥옥", "해망굴"])]), ("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님", "이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.", [("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]), @@ -414,7 +435,7 @@ THEMES = [ ("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님", "빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.", [("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]), - ("last", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]), + ("last_car", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]), ("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님", "부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.", [("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]), @@ -422,15 +443,15 @@ THEMES = [ ("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님", "소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.", [("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]), - ("last", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]), + ("last_car", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]), ("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님", "걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.", [("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]), - ("last", ["경암동 철길마을", "이성당", "월명호수"])]), + ("last_car", ["경암동 철길마을", "이성당", "월명호수"])]), ("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님", "첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.", [("first_late", ["명월갈비"]), - ("last_plus", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]), + ("last_plus_car", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]), ("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님", "하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.", @@ -441,7 +462,7 @@ THEMES = [ "가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.", [("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]), ("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]), - ("last", ["초원사진관", "일흥옥", "이성당"])]), + ("last_car", ["초원사진관", "일흥옥", "이성당"])]), ("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님", "원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.", [("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]), @@ -451,22 +472,22 @@ THEMES = [ "끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.", [("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]), ("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]), - ("last", ["동국사", "국제반점", "군산근대역사박물관"])]), + ("last_car", ["동국사", "국제반점", "군산근대역사박물관"])]), ("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님", "가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.", [("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]), ("mid_rest", ["만남스넥", "일흥옥"]), - ("last", ["동국사", "물밀소", "월명호수"])]), + ("last_car", ["동국사", "물밀소", "월명호수"])]), ("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님", "시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.", [("first", ["초원사진관", "동국사", "도란", "군산 내항"]), ("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]), - ("last", ["우체통거리", "일흥옥", "군산근대미술관"])]), + ("last_car", ["우체통거리", "일흥옥", "군산근대미술관"])]), ("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님", "사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.", [("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]), ("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]), - ("last", ["채만식문학관", "한일옥", "이성당"])]), + ("last_car", ["채만식문학관", "한일옥", "이성당"])]), ("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님", "가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.", [("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]), @@ -476,7 +497,7 @@ THEMES = [ "가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.", [("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]), ("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]), - ("last", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]), + ("last_car", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]), ("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님", "소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.", [("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]), @@ -486,7 +507,7 @@ THEMES = [ "기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.", [("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]), ("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]), - ("last", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]), + ("last_car", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]), ] # 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 — @@ -522,11 +543,21 @@ for name, duration, audience, why, days in THEMES: built = [build_day(kind, places, DAY_LABEL[i], (starts[i] if starts and i < len(starts) else None), pace) for i, (kind, places) in enumerate(days)] - items.append({"name": name, "duration": duration, "audience": audience, "why": why, - "days": built}) + # ★ 차 필요 여부를 손님이 테마를 **고르기 전에** 보이게 한다 (2026-09-14 대표: "차로 + # 가는거 아닌거는 대중교통 이용이라고 따로 표시해두는게 어때"). 이 신호는 원래 + # 마지막 날 체크아웃 칸의 note 에만 있었다 — 카드를 펼쳐 마지막 정거장까지 봐야 + # 나오는 자리라, 테마 이름만 보고 고르는 손님에게는 안 보였다. + # ★ '대중교통'이라고는 쓰지 않는다. 이 7개 테마는 버스·택시가 필요한 게 아니라 + # 전 정거장이 머뭄에서 1.5km 안(WALK_LIMIT_KM)이라 걸어서 다 된다 — 실제로 + # 안 쓰는 교통수단을 이름에 올리면 손님이 없는 버스편을 찾아보게 만든다. + needs_car = days[-1][0] in CAR_KINDS + tag = "차량 필요" if needs_car else "도보로 충분 (차 없이 가능)" + items.append({"name": name, "duration": duration, "audience": f"{audience} · {tag}", + "why": why, "days": built}) env = {"kind": "itinerary", "version": 1, "title": "추천 일정", - "subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다", "items": items} + "subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다 — 떠나는 날만 그대로 길을 나섭니다", + "items": items} SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False) @@ -543,6 +574,8 @@ FOOD = {"빈해원", "일흥옥", "한일옥", "명월갈비", "국제반점", " INDOOR = {"군산근대역사박물관", "군산근대건축관", "군산근대미술관", "채만식문학관", STAY} | FOOD NEEDS_CAR = {"고군산군도 선유도", "장자도", "채만식문학관", "경암동 철길마을", "월명호수", "은적사"} REST_SLOTS = {"쉼", "머뭄에서 쉬는 날"} +# 마지막 날을 **복귀 없이** 끝내는 테마 — 차로 온 손님이다. 뼈대에서 곧바로 뽑는다(손으로 적지 않는다). +CAR_LAST_THEMES = {name for name, _dur, _aud, _why, days in THEMES if days[-1][0] in CAR_KINDS} NIGHT_ONLY = {"군산 내항"} BAR = {"도란"} DAYS_OF = {"1박 2일": 2, "2박 3일": 3} @@ -564,13 +597,24 @@ def audit(built: list[dict]) -> list[str]: slots = [s["name"].split(" · ")[0] for s in day["stops"]] places = [s["name"].split(" · ")[-1] for s in day["stops"]] seen_all += [p for p in places if p != STAY] + last_day = day is item["days"][-1] + car_end = last_day and item["name"] in CAR_LAST_THEMES - # ① 하루의 처음과 끝은 머뭄 + # ① 하루의 처음과 끝은 머뭄 — 단, 차로 온 손님의 마지막 날은 짐을 싣고 떠난 뒤라 복귀가 없다 if places[0] != STAY: flag(item, day, f"1번이 머뭄이 아니다 ({places[0]})") - if places[-1] != STAY: + if places[-1] != STAY and not car_end: flag(item, day, f"마지막이 머뭄이 아니다 ({places[-1]})") + # ⑰ 떠나는 날의 끝맺음이 테마와 맞는가 (2026-09-14) + # 차 테마인데 복귀 칸이 남아 있으면 짐을 차에 싣고 숙소로 다시 오는 일정이 되고, + # 도보 테마인데 복귀 칸이 없으면 맡긴 짐을 못 찾고 하루가 끝난다. 둘 다 화면만 봐서는 + # 멀쩡해 보인다 — 뼈대를 바꿀 때 한쪽만 고치고 넘어가는 자리라 검사에 둔다. + if car_end and (places[-1] == STAY or slots[-1] == "마무리"): + flag(item, day, f"차 테마인데 복귀 칸이 남았다 ({slots[-1]})") + if last_day and not car_end and slots[-1] != "마무리": + flag(item, day, f"도보 테마인데 짐을 찾는 마무리 칸이 없다 ({slots[-1]})") + # ② 같은 자리가 연달아 서지 않는다 for i in range(len(places) - 1): if places[i] == places[i + 1]: @@ -596,7 +640,7 @@ def audit(built: list[dict]) -> list[str]: flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖") # ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다 - if i == 0 and slots[0] == "아침" and len(item["days"]) == want and day is item["days"][-1]: + if i == 0 and slots[0] == "아침" and last_day: if arrive + stop["minutes"] > CHECK_OUT_BY: end = arrive + stop["minutes"] flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다") @@ -678,4 +722,4 @@ for item in items: f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸") DST.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") -print(f"\n테마 {len(items)}개 · 규칙 16종 위반 0건 · payload {DST.stat().st_size:,}B") +print(f"\n테마 {len(items)}개 · 규칙 17종 위반 0건 · payload {DST.stat().st_size:,}B") 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/build_story.py b/solution/site/scripts/mockup/build_story.py index b3d3cc4..fa602de 100644 --- a/solution/site/scripts/mockup/build_story.py +++ b/solution/site/scripts/mockup/build_story.py @@ -121,6 +121,9 @@ PEOPLE = [ ("김수미", "김수미", "1949–2024", "배우", "「전원일기」 일용 엄니로 오래 기억되는 배우입니다."), ("송새벽", "송새벽", "1979–", "배우", "군산 출신 배우입니다."), ("장신영", "장신영", "1984–", "배우", "군산 출신 배우입니다."), + # ★ 익산 출생이지만 군산에서 학교를 다녔다 — 그대로 적는다. 다른 줄처럼 "군산 출신" 으로 + # 뭉뚱그리면 위키와 어긋나고, 이 목록의 규칙(문서에 적힌 관계만 담는다)도 깨진다. + ("진희경", "진희경", "1968–", "배우", "익산에서 태어나 군산영광여고를 나온 배우입니다."), ("이서원", "이서원", "1997–", "배우", "군산 출신 배우입니다."), ("장재호 (배우)", "장재호", "1986–", "배우", "군산 출신 배우입니다."), ("강민지 (배우)", "강민지", "1996–", "배우 · 모델", "모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다."), 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/fetch_restaurants.py b/solution/site/scripts/mockup/fetch_restaurants.py new file mode 100644 index 0000000..81fa234 --- /dev/null +++ b/solution/site/scripts/mockup/fetch_restaurants.py @@ -0,0 +1,99 @@ +"""주변 맛집을 TourAPI 에서 다시 받아 `restaurants.json` 으로 떨어뜨린다. patch_stay.py 가 이 파일을 싣는다. + +★ 왜 (2026-09-11, 사장님: "주변맛집 데이터가 너무 적은거같아") + 원본 사이트의 맛집은 8곳이었고 전부 467m 안이었다. 그래서 '걸어서 10분 이상' 탭을 누르면 + 맛집 칸이 "이 거리 안에는 없습니다" 로 비었다. 같은 좌표 TourAPI 5km 반경에는 59곳이 있다 + (제품은 5km 를 싣는다 — local_content_service.RESTAURANT_RADIUS_M). +★ 2km(걸어서 25분)까지만 싣는다. 그 밖은 차로 갈 거리라 '주변' 이라 부르기 어렵고, + 레일에 59장이 서면 가까운 집이 묻힌다. +★ 원래 8곳은 그대로 둔다 — 소개문과 사진이 원본 사이트 것이다. 새로 붙는 곳만 TourAPI 에서 받는다. +★ 사진은 공공누리 상업 이용 가능분만 온다(tour_api._normalize 가 거른다). 받아서 img/mirror/ 에 + 둔다 — 시연본은 레포 안에서 자급자족한다(README 2.2). 사진 없는 집은 렌더러가 이름 활자로 세운다. + +실행 (solution/backend 에서 — 백엔드 설정이 .env 를 거기서 읽는다): + .venv/bin/python ../site/scripts/mockup/fetch_restaurants.py +""" +import asyncio +import hashlib +import json +import re +import sys +from pathlib import Path + +import httpx + +SP = Path(__file__).resolve().parent +sys.path.insert(0, str(SP.parents[2] / "backend")) +from services.external.tour_api import _call, fetch_nearby # noqa: E402 + +LAT, LNG = 35.9864175, 126.7061254 # payload.place — 스테이 머뭄 +RADIUS_M = 2_000 + + +def meters(text: str) -> float: + m = re.match(r"^([\d.]+)\s*(km|m)$", (text or "").strip(), re.I) + return float(m[1]) * (1000 if m[2].lower() == "km" else 1) if m else float("inf") + + +def distance_text(m: int) -> str: + """site_payload 와 같은 표기 — 렌더러가 이 문자열을 다시 미터로 읽어 거리 탭을 가른다.""" + return f"{m}m" if m < 1000 else f"{(m + 50) // 100 / 10:.1f}km" + + +def clean_name(title: str) -> str: + # "[백년가게]운정식당" · "[착한가게] 국일식당" — 인증 꼬리표는 이름이 아니다. 검색어에 붙으면 가게가 안 잡힌다. + return re.sub(r"^\[[^\]]*\]\s*", "", title).strip() + + +def blurb(overview: str) -> str: + """overview 앞 두 문장까지. 카드는 세 줄에서 자른다(line-clamp-3) — 원래 8곳도 두 문장 안팎이다.""" + text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", overview or "")).strip() + sentences = re.split(r"(?<=[.!?])\s+", text) + out = sentences[0] if sentences else "" + if len(sentences) > 1 and len(out) + len(sentences[1]) < 110: + out += " " + sentences[1] + return out + + +async def mirror(client: httpx.AsyncClient, url: str) -> str: + ext = Path(url.split("?")[0]).suffix.lower() or ".jpg" + name = hashlib.sha1(url.encode()).hexdigest()[:16] + ext + path = SP / "img" / "mirror" / name + if not path.exists(): + res = await client.get(url) + res.raise_for_status() + path.write_bytes(res.content) + # patch_stay.py 가 /assets/mirror/ → /s/stay/img/mirror/ 로 바꿔 박는다(원래 8곳과 같은 길). + return f"/assets/mirror/{name}" + + +async def main() -> None: + original = json.loads((SP / "stay-payload.json").read_text(encoding="utf-8"))["local"]["restaurants"] + have = {r["name"] for r in original} + out = list(original) + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + rows = await fetch_nearby(client, LAT, LNG, radius_m=RADIUS_M, content_type_id="39") + for row in rows: + name = clean_name(row["name"]) + if name in have: + continue + items, _ = await _call(client, "detailCommon2", contentId=row["contentid"]) + entry = {"name": name, "category": "맛집", "searchQuery": name, + "distanceText": distance_text(row["distance_m"])} + if row.get("imageUrl"): + entry["imageUrl"] = await mirror(client, row["imageUrl"]) + desc = blurb(items[0].get("overview") if items else "") + if desc: + entry["description"] = desc + out.append(entry) + have.add(name) + # 거리순 — 레일 앞머리가 가장 가까운 집이어야 '주변' 이다. + out.sort(key=lambda r: meters(r.get("distanceText"))) + (SP / "restaurants.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + far = sum(1 for r in out if meters(r.get("distanceText")) > 800) + photo = sum(1 for r in out if r.get("imageUrl")) + print(f"restaurants.json · {len(out)}곳 (원본 {len(original)} + TourAPI {len(out) - len(original)}) " + f"· 사진 {photo} · 800m 밖 {far}") + + +asyncio.run(main()) diff --git a/solution/site/scripts/mockup/fill_restaurant_photos.py b/solution/site/scripts/mockup/fill_restaurant_photos.py new file mode 100644 index 0000000..5bfe1d6 --- /dev/null +++ b/solution/site/scripts/mockup/fill_restaurant_photos.py @@ -0,0 +1,47 @@ +"""TourAPI 에 사진이 없는 주변 맛집에 네이버 이미지 검색으로 찾은 사진을 받아 둔다. + +★ 왜 (2026-09-11, 사장님: "이미지가 없어?? 좀 가져와봐" · "다운로드 하던가") + fetch_restaurants.py 가 붙인 23곳 중 11곳은 TourAPI(위치 목록·추가 이미지·키워드 검색)와 + 위키미디어 어디에도 사진이 없었다. 레일에 이름 활자 카드가 11장 섞였다. +★ ⚠ 이 사진들은 공공누리가 아니다. 대한민국 구석구석·군산시청·블로그·네이버 플레이스에 올라온 것이고 + 권리는 각 게시자에게 있다. **시연본에만 쓴다 — 제품 수집 파이프라인으로 옮기지 않는다.** + 제품의 규칙은 그대로 공공누리 Type1·Type3 뿐이다(tour_api._COMMERCIAL_OK_LICENSES). +★ 한 장씩 눈으로 보고 골랐다 — 간판이 보이거나 게시물 제목에 그 가게 이름이 있는 것만. + 이름만 같은 다른 집(거목아리랑 · 국일복아구)은 버렸다. 출처 주소는 restaurant-photos.json 에 남긴다. +★ 긴 변 1600px 로 줄인다 — 원본이 10MB 인 것도 있다. 작은 사진은 키우지 않는다. + +실행: python3 fill_restaurant_photos.py (macOS `sips` 로 줄인다) +굽기(patch_stay.py)는 여기서 받아 둔 파일만 읽는다 — 사진이 없는 집에만 덧대고, TourAPI 사진이 있으면 그쪽이 먼저다. +""" +import hashlib +import json +import re +import subprocess +import urllib.request +from pathlib import Path + +SP = Path(__file__).resolve().parent +LIST = SP / "restaurant-photos.json" +MAX_SIDE = 1600 + +photos = json.loads(LIST.read_text(encoding="utf-8")) +for name, ph in photos.items(): + file = hashlib.sha1(ph["url"].encode()).hexdigest()[:16] + ".jpg" + path = SP / "img" / "mirror" / file + if not path.exists(): + src = path.with_suffix(".src") + req = urllib.request.Request(ph["url"], headers={"User-Agent": "Mozilla/5.0", "Referer": "https://search.naver.com/"}) + src.write_bytes(urllib.request.urlopen(req, timeout=30).read()) + dims = subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(src)], + capture_output=True, text=True, check=True).stdout + side = max(int(v) for v in re.findall(r"pixel(?:Width|Height): (\d+)", dims)) + cmd = ["sips", "-s", "format", "jpeg", "-s", "formatOptions", "80"] + if side > MAX_SIDE: + cmd += ["-Z", str(MAX_SIDE)] + subprocess.run(cmd + [str(src), "--out", str(path)], capture_output=True, check=True) + src.unlink() + ph["file"] = file + print(f" {name:<8} {path.stat().st_size // 1024:>5}KB {file}") + +LIST.write_text(json.dumps(photos, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") +print(f"restaurant-photos.json · {len(photos)}곳") diff --git a/solution/site/scripts/mockup/img/mirror/00a8ac4ce017fb12.jpg b/solution/site/scripts/mockup/img/mirror/00a8ac4ce017fb12.jpg new file mode 100644 index 0000000..10e4931 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/00a8ac4ce017fb12.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/067f6fe42c045a3a.jpg b/solution/site/scripts/mockup/img/mirror/067f6fe42c045a3a.jpg new file mode 100644 index 0000000..eb0379f Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/067f6fe42c045a3a.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/087900c82da3b2f5.jpg b/solution/site/scripts/mockup/img/mirror/087900c82da3b2f5.jpg new file mode 100644 index 0000000..b1778fc Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/087900c82da3b2f5.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/1302460cb149975f.jpg b/solution/site/scripts/mockup/img/mirror/1302460cb149975f.jpg new file mode 100644 index 0000000..1fb2fd4 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/1302460cb149975f.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/24043ae4f5854f96.jpg b/solution/site/scripts/mockup/img/mirror/24043ae4f5854f96.jpg new file mode 100644 index 0000000..1bf16d6 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/24043ae4f5854f96.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/3e5b3daae33ea51c.jpg b/solution/site/scripts/mockup/img/mirror/3e5b3daae33ea51c.jpg new file mode 100644 index 0000000..10338f6 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/3e5b3daae33ea51c.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/42e8d872198edaa9.jpg b/solution/site/scripts/mockup/img/mirror/42e8d872198edaa9.jpg new file mode 100644 index 0000000..13b5b89 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/42e8d872198edaa9.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/483f52c2d2b7eebe.jpg b/solution/site/scripts/mockup/img/mirror/483f52c2d2b7eebe.jpg new file mode 100644 index 0000000..3ad803c Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/483f52c2d2b7eebe.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/55f57f45fcebb460.jpg b/solution/site/scripts/mockup/img/mirror/55f57f45fcebb460.jpg new file mode 100644 index 0000000..c3ad8aa Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/55f57f45fcebb460.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/5f92192c7b3e2cbf.jpg b/solution/site/scripts/mockup/img/mirror/5f92192c7b3e2cbf.jpg new file mode 100644 index 0000000..1af845d Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/5f92192c7b3e2cbf.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/6102b164464dbfa4.jpg b/solution/site/scripts/mockup/img/mirror/6102b164464dbfa4.jpg new file mode 100644 index 0000000..6bb41a1 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/6102b164464dbfa4.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/81438670a5485351.jpg b/solution/site/scripts/mockup/img/mirror/81438670a5485351.jpg new file mode 100644 index 0000000..76bd74e Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/81438670a5485351.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/81bd76fa51ae9ad1.jpg b/solution/site/scripts/mockup/img/mirror/81bd76fa51ae9ad1.jpg new file mode 100644 index 0000000..c8fed22 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/81bd76fa51ae9ad1.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/83766fa52745ef37.jpg b/solution/site/scripts/mockup/img/mirror/83766fa52745ef37.jpg new file mode 100644 index 0000000..fa6f3fe Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/83766fa52745ef37.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/92cb907d07db9953.jpg b/solution/site/scripts/mockup/img/mirror/92cb907d07db9953.jpg new file mode 100644 index 0000000..4715d29 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/92cb907d07db9953.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/93146c8ae53252ff.jpg b/solution/site/scripts/mockup/img/mirror/93146c8ae53252ff.jpg new file mode 100644 index 0000000..5b4e725 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/93146c8ae53252ff.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/98539e6361e317b1.jpg b/solution/site/scripts/mockup/img/mirror/98539e6361e317b1.jpg new file mode 100644 index 0000000..7cae3b5 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/98539e6361e317b1.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/aebe437fd281f9ad.jpg b/solution/site/scripts/mockup/img/mirror/aebe437fd281f9ad.jpg new file mode 100644 index 0000000..61ac31a Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/aebe437fd281f9ad.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/b3518ee8ef2b4aac.png b/solution/site/scripts/mockup/img/mirror/b3518ee8ef2b4aac.png new file mode 100644 index 0000000..dd6e854 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/b3518ee8ef2b4aac.png differ diff --git a/solution/site/scripts/mockup/img/mirror/bf6bb6183c6f6246.jpg b/solution/site/scripts/mockup/img/mirror/bf6bb6183c6f6246.jpg new file mode 100644 index 0000000..e3ab454 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/bf6bb6183c6f6246.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/de286714af7faaba.jpg b/solution/site/scripts/mockup/img/mirror/de286714af7faaba.jpg new file mode 100644 index 0000000..ab51812 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/de286714af7faaba.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/e13c8a2a1aa2762c.jpg b/solution/site/scripts/mockup/img/mirror/e13c8a2a1aa2762c.jpg new file mode 100644 index 0000000..fd908b5 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/e13c8a2a1aa2762c.jpg differ diff --git a/solution/site/scripts/mockup/img/mirror/f9cb7e88415edd06.jpg b/solution/site/scripts/mockup/img/mirror/f9cb7e88415edd06.jpg new file mode 100644 index 0000000..bce4eb1 Binary files /dev/null and b/solution/site/scripts/mockup/img/mirror/f9cb7e88415edd06.jpg differ 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`)는 판 아래 곡명을 **항상** 보여준다 — + * `<span className="mt-2 block truncate text-[10px] ...">{song.title}</span>`, + * 숨기는 코드가 없다. 그런데 같은 날 앞선 지적("LP판이 많아져서 보기가 이상하잖아", + * 25곡이 `flex-wrap` 으로 3~9줄이 되던 문제)에 답하며 판을 겹쳐 꽂고 **곡명을 + * hover 로만 띄우게** 고쳤다 — 데스크톱 벽은 없앴지만 손가락엔 hover 가 없으니 + * 곡명이 아예 안 보이는 화면이 됐다. 데스크톱만 보고 고친 값이다. + * ★ 다시 짠다 — **곡명은 항상 보인다. 줄은 접지 않고 가로 한 줄로만 편다.** + * 판이 25곡이든 50곡이든 줄 수는 하나고, 안 보이는 나머지는 옆으로 스크롤한다. + * 이 페이지의 다른 레일(주변 명소·인물 열전)이 이미 쓰는 것과 같은 해법이라 — + * "hover 로 감추기" 는 애초에 필요 없던 처방이었다. + * ★ 마크업을 고치지 않는다(React 가 되돌린다). `:has()` 로 판이 든 상자만 집는다. + * 곡명 `<span>` 은 원래도 절대배치가 아니라 `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 로 다시 그리면 잘라낸 + * <li> 는 돌아오지만, 그때는 주입 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..f65f8ff 100644 --- a/solution/site/scripts/mockup/inject.js +++ b/solution/site/scripts/mockup/inject.js @@ -43,24 +43,63 @@ var items = catchphrases(); if (items.length === 0) return; + /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: + "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). + 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — + 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ + /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: + "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). + 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — + 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ var base = ((P.narrative && P.narrative.tagline) || '').trim(); var node = null; - var last = null; - /* ★ 노드를 들고 있지 않고 매번 찾는다. + /* ★ 노드를 들고 있지 않고 매번 확인한다. 이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에 - 실패하면 #root 를 **통째로 다시 그린다** — 그때 히어로의 <p> 가 새 노드로 바뀐다. - 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ - function findNode() { - var wanted = [base]; - if (last) wanted.push(last); + 실패하면 #root 를 **통째로 다시 그린다** — 대표 문구 <p> 가 새 노드로 바뀌고 그 아래 붙여 둔 + 줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ + function findTagline() { var candidates = document.querySelectorAll('#root p'); for (var i = 0; i < candidates.length; i += 1) { - if (wanted.indexOf(candidates[i].textContent.trim()) >= 0) return candidates[i]; + if (candidates[i].textContent.trim() === base) return candidates[i]; } return null; } - if (!findNode()) return; + function ensureNode() { + if (node && node.isConnected) return node; + var tagline = findTagline(); + if (!tagline) return null; + node = document.createElement('p'); + node.id = 'w4d-sub'; + node.className = 'w4d-sub measure'; + tagline.insertAdjacentElement('afterend', node); + reserve(); + return node; + } + if (!base || !findTagline()) return; + + /* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면 + 숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */ + function reserve() { + var width = node.getBoundingClientRect().width; + if (!width) return; + var probe = node.cloneNode(false); + probe.removeAttribute('id'); + probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px'; + node.parentNode.appendChild(probe); + var max = 0; + items.forEach(function (it) { + probe.textContent = it.text; + max = Math.max(max, probe.getBoundingClientRect().height); + }); + probe.remove(); + node.style.minHeight = Math.ceil(max) + 'px'; + } + var resizeTimer = null; + window.addEventListener('resize', function () { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200); + }); var byKind = function (kind) { return items.filter(function (it) { return it.kind === kind; }); @@ -94,6 +133,44 @@ } return out.filter(Boolean); } + 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 cycle = []; + function buildCycle() { return items.filter(function (item) { return item.kind === "general"; }); } /* 문장 하나를 단어 칸으로 조립한다. innerHTML 을 안 쓰는 이유는 문구가 데이터라서다 — 지금은 우리가 쓴 문장이지만, 이 자리는 나중에 서버가 채운다. */ @@ -118,23 +195,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 +239,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); + window.setInterval(function () { if (!document.hidden && !REDUCED) tick(); }, 6000); + }, 1200); } /* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════ @@ -348,6 +417,7 @@ if (started && started.catch) started.catch(function () { playing = false; render(); }); playing = true; render(); + return started; // 자동재생이 막혔는지 부르는 쪽이 알아야 한다 } /* 패널은 fixed 라 헤더 밑에 손으로 붙인다 — 헤더가 sticky 라 스크롤해도 자리가 같다. */ @@ -377,6 +447,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 +487,99 @@ 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(); + /* ★ 자동재생을 **끈다** (2026-09-15 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 + 사람들이 에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다 — 09-11 "자동 빼" → 09-14 "자동재생 + 한다" → 09-15 끈다. 뒤집은 이유가 매번 다르다: 이번은 **정책을 못 이겨서**가 아니라 + 정책에 걸린 그 모습이 고장으로 읽혀서다. 브라우저는 손님이 페이지를 만지기 전에는 + 소리를 거절하므로(정책), 자동재생을 켜 두면 "눌러야 나는 플레이어"가 되고 그건 + 고장과 구별되지 않는다. + ★ 함수는 남긴다 — 네 번째 뒤집기가 오면 이 한 줄만 되살린다. 지운 자리를 다시 짜면 + 첫 손길 리스너의 클릭 삼킴(2026-09-11)을 또 밟는다. + ★ 제품 렌더러(`site/src/sections/SongPlayer.tsx`)에는 원래 자동재생이 없다 — + 이제 목업과 제품이 같은 동작이다. */ + void autoplay; if (window.MutationObserver) { new MutationObserver(function () { if (!mini.isConnected) { mount(); render(); } @@ -434,7 +589,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개 정도만 보여주는거 어때?" → "랜덤으로"). 나머지는 + * 다음에 들어왔을 때 나온다 — 접은 게 아니라 돌려 보여주는 것이라 `<details>` 를 쓰지 + * 않는다(문서에 다 남겨야 하는 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, '>') + .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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + + 'stroke-linecap="round" stroke-linejoin="round" class="size-5"><path d="m15 18-6-6 6-6"/></svg>'; + var CHEVRON_R = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + + 'stroke-linecap="round" stroke-linejoin="round" class="size-5"><path d="m9 18 6-6-6-6"/></svg>'; + 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 대표: "군산읽기만 타이틀이 없서 통일 시켜"). 탭 버튼 + 라벨과 겹쳐 보여서 처음엔 뺐는데, 정작 실제 다섯 탭(가요 다방·인물 열전 등)은 + 전부 안에도 <h2> 를 그대로 세워 둔다(`StorySection.tsx`: "탭 이름과 겹쳐 보이지만 + …읽는 기계에게는 그 제목이 다섯 덩이를 가르는 유일한 표지다") — 나만 빼서 되레 + 튀었다. 같은 마크업(`header.mb-8.sm:mb-10 > h2.h2 + p.measure`)으로 맞춘다. */ + var html = '<header class="mb-8 sm:mb-10">' + + '<h2 id="w4d-reading-heading" class="h2">' + escapeHtml(env.title || '군산 읽기') + '</h2>' + + '<p class="measure mt-3 text-[length:var(--fs-sm)] opacity-70">' + escapeHtml(env.subtitle) + '</p>' + + '</header>'; + + /* ↓ 여기부터 Carousel.tsx 의 실제 출력(화살표 header · slider-viewport/track · 점 · 카운터)을 + 그대로 옮긴 것이다 — 클래스 이름 하나까지 같다(파일 머리 주석 참고). */ + html += '<div class="mb-3 flex justify-end gap-2">' + + '<button type="button" class="w4d-read-prev ' + NAV_BTN + '" aria-label="군산 읽기 이전" disabled>' + CHEVRON_L + '</button>' + + '<button type="button" class="w4d-read-next ' + NAV_BTN + '" aria-label="군산 읽기 다음">' + CHEVRON_R + '</button>' + + '</div>'; + html += '<div class="relative">' + + '<div class="slider-viewport" role="group" aria-roledescription="캐러셀" ' + + 'aria-label="군산 읽기" tabindex="0">' + + '<div class="slider-track" style="gap:1rem" id="' + trackId + '">'; + picked.forEach(function (row, index) { + var item = row.item; + var source = item.source || {}; + // 출처는 항목마다 단다 — 개별 문서 대신 네이버 검색 링크로 통일했다(2026-09-14 + // 대표: "확인필요 없애고 링크는 네이버 링크로", `build_reading.py` `naver()`). + var credit = source.url + ? '<a href="' + escapeHtml(source.url) + '" target="_blank" rel="noopener noreferrer">' + + escapeHtml(source.name) + '</a>' + : escapeHtml(source.name || ''); + html += '<div class="min-w-0 shrink-0 grow-0 w4d-read-basis" aria-roledescription="슬라이드" ' + + 'id="w4d-read-slide-' + index + '">' + + '<article class="w4d-read-card">' + + '<p class="w4d-read-page">' + escapeHtml(row.group) + + (item.year ? ' · <span class="w4d-read-year">' + escapeHtml(item.year) + '</span>' : '') + '</p>' + + '<h3>' + escapeHtml(item.title) + '</h3>' + + '<p class="w4d-read-body">' + escapeHtml(item.body) + '</p>' + + '<p class="w4d-read-src">출처 · ' + credit + '</p></article></div>'; + }); + html += '</div></div></div>'; + + html += '<div class="mt-4 flex items-center justify-center gap-3">'; + if (picked.length <= 12) { + html += '<ul class="flex items-center gap-1.5">'; + picked.forEach(function (_row, index) { + html += '<li><button type="button" class="w4d-read-dot h-1.5 rounded-full transition-all ' + + (index === 0 ? 'w-6 bg-current opacity-70' : 'w-1.5 bg-current opacity-20 hover:opacity-45') + '" ' + + 'aria-label="' + (index + 1) + '번째로" aria-current="' + (index === 0 ? 'true' : 'false') + '" ' + + 'aria-controls="' + trackId + '" data-index="' + index + '"></button></li>'; + }); + html += '</ul>'; + } + html += '<span class="w4d-read-counter text-current/55 text-[length:var(--fs-xs)] tabular-nums">' + + '<span data-cur>1</span> / ' + picked.length + '</span></div>'; + + 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 = '<div class="shell">' + html + '</div>'; + + 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` 로 감싸지 않는다 — 그냥 + * `<div className="flex flex-wrap ...">{판들}</div>` 이다(`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 = + '<button type="button" class="w4d-songs-prev ' + NAV_BTN + '" aria-label="가요 다방 이전" disabled>' + CHEVRON_L + '</button>' + + '<button type="button" class="w4d-songs-next ' + NAV_BTN + '" aria-label="가요 다방 다음">' + CHEVRON_R + '</button>'; + 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 = '<span data-cur>1</span> / ' + 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 +1144,7 @@ } - /* ══ ④ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════ + /* ══ ⑤ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════ * * ★ 이 페이지의 카로셀은 **구현이 둘**이다. * ① embla-carousel(라이브러리) — 일정·축제·주변 안내. `.slider-viewport` 를 쓰고 @@ -689,12 +1296,752 @@ }, 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 = + '<div class="shell">' + + '<header class="mb-8 sm:mb-10">' + + '<h2 id="w4d-pm-heading" class="h2">엽서 쓰기</h2>' + + '<p class="measure mt-3 text-[length:var(--fs-sm)] opacity-70">' + + escapeHtml(placeName) + '에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 ' + + '그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.</p>' + + '</header>' + + '<div class="w4d-pm-layout">' + + '<canvas class="w4d-pm-canvas" width="1080" height="1080" aria-label="내가 만든 엽서 미리보기"></canvas>' + + '<div class="w4d-pm-form">' + + /* 썸네일 줄도 이 사이트의 진짜 카로셀 껍데기를 그대로 쓴다(가요 다방·군산 읽기와 + 같은 근거 — README `startSongsRail` 주석 참고). role=radiogroup 은 안쪽 + track 이 아니라 이 뷰포트에 둔다 — 스크린리더가 "그룹" 보다 "라디오군" 을 + 먼저 읽어야 사진 고르기라는 게 전달된다. */ + '<div class="w4d-pm-thumbs-viewport slider-viewport" role="radiogroup" ' + + 'aria-label="엽서 사진 고르기(' + photos.length + '장)" tabindex="0">' + + '<div class="w4d-pm-thumbs slider-track" id="w4d-pm-thumbs-track">' + + photos.map(function (m, i) { + return '<button type="button" class="w4d-pm-thumb" data-i="' + i + '" ' + + 'aria-pressed="' + (i === 0 ? 'true' : 'false') + '" aria-label="사진 ' + (i + 1) + '">' + + '<img src="' + escapeHtml(m.url) + '" alt="" loading="lazy"></button>'; + }).join('') + + '</div></div>' + + '<textarea class="w4d-pm-text" maxlength="60" rows="3" ' + + /* ★ 이 업소 이름을 예시에 박지 않는다(2026-09-14 대표: "다른 숙박업소나 카페도 + 써야 하는데 이렇게 써놓으면 어떡해") — 어느 업종에 붙여도 맞는 말로 남긴다. */ + 'placeholder="엽서에 쓸 한 마디를 적어 보세요"></textarea>' + + '<p class="w4d-pm-count"><span data-len>0</span>/60</p>' + + '<div class="w4d-pm-actions">' + + '<button type="button" class="w4d-pm-share"></button>' + + '<button type="button" class="w4d-pm-save">이미지로 저장</button>' + + '</div>' + + '<p class="w4d-pm-hint" hidden>이 브라우저는 공유 시트를 못 열어 저장으로 대신합니다.</p>' + + '</div></div></div>'; + + 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); + } + + /* 하늘 줄은 <p> 자체가 문장이고, 기온 줄은 배지 <span> 옆의 <span> 이 문장이다. + 클래스로 찾지 않는다 — 번들 판이 바뀌면 달라지고 두 줄 다 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` 은 좁은 화면에서 한 장씩 미는 캐러셀이고, 넓은 화면에서는 같은 <ul> 이 + * 4열 격자가 된다(`lg:grid lg:grid-cols-4`). 사진 37장이면 열 줄 — 갤러리 하나가 + * 페이지를 끊는다. 그래서 넓은 화면에서만 12장(3줄)씩 끊어 보여 준다. + * + * ★ <li> 를 **지우지 않는다.** 숨기기는 `data-w4d-gal-on='0'` + 주입 CSS 가 하고, 그 규칙은 + * `min-width: 1024px` 안에만 있다. 두 가지 이유다. + * · 좁은 화면 캐러셀은 손대지 않아야 한다 — 거기선 이미 한 번에 한 장이다. + * · 사진이 DOM 에 남아 있어야 alt 문장이 문서에 남는다. 렌더러도 같은 이유로 배리에이션 + * 셋 모두에서 사진을 전부 싣는다(`GallerySection.tsx` 머리주석). + * + * ★ 크게 보기(모달)와 어긋나지 않는다. 모달의 앞/뒤는 React 가 `images` **배열 색인**으로 + * 도는 것이라 화면에 무엇이 보이는지와 무관하다 — 3쪽에서 연 사진도 ←/→ 로 37장을 다 돈다. + * + * ★ 페이저는 React 가 그리는 상자(`div.relative`) 안에 꽂는다. 재렌더에 날아가므로 + * #root 를 감시해 다시 꽂는다(② 헤더 단추와 같은 방식). 다시 부를 수 있게 만들었다 — + * 이미 꽂혀 있으면 표시만 다시 찍는다. + */ + var GAL_PER_PAGE = 12; // 4열 × 3줄. 줄 수를 바꾸려면 4의 배수로 둔다 + + function galleryPager() { + var box = document.getElementById('gallery'); + if (!box) return; + var list = box.querySelector('ul'); + if (!list) return; + + var items = list.children; + var total = items.length; + // 한 쪽에 다 들어가면 페이저를 만들지 않는다 — 단추 한 개짜리 줄만 남는다. + if (total <= GAL_PER_PAGE) return; + + var pages = Math.ceil(total / GAL_PER_PAGE); + var pager = box.querySelector('.w4d-gal-pager'); + var fresh = !pager; + + if (fresh) { + pager = document.createElement('nav'); + pager.className = 'w4d-gal-pager'; + pager.setAttribute('aria-label', '사진 갤러리 쪽 넘기기'); + pager.dataset.w4dGalPage = '1'; + + var html = '<button type="button" data-w4d-gal-step="-1" aria-label="이전 쪽">‹</button>'; + for (var i = 1; i <= pages; i += 1) { + html += '<button type="button" data-w4d-gal-page="' + i + '">' + i + '</button>'; + } + html += '<button type="button" data-w4d-gal-step="1" aria-label="다음 쪽">›</button>' + + '<span class="w4d-gal-total">사진 ' + total + '장</span>'; + pager.innerHTML = html; + + pager.addEventListener('click', function (event) { + var hit = event.target.closest('button'); + if (!hit) return; + var now = Number(pager.dataset.w4dGalPage) || 1; + var next = hit.dataset.w4dGalPage + ? Number(hit.dataset.w4dGalPage) + : now + Number(hit.dataset.w4dGalStep); + if (next < 1 || next > pages || next === now) return; + pager.dataset.w4dGalPage = String(next); + paint(); + }); + } + + function paint() { + var page = Number(pager.dataset.w4dGalPage) || 1; + var from = (page - 1) * GAL_PER_PAGE; + list.dataset.w4dGal = '1'; + for (var i = 0; i < items.length; i += 1) { + items[i].dataset.w4dGalOn = (i >= from && i < from + GAL_PER_PAGE) ? '1' : '0'; + } + var buttons = pager.querySelectorAll('button'); + for (var b = 0; b < buttons.length; b += 1) { + var button = buttons[b]; + if (button.dataset.w4dGalPage) { + button.setAttribute('aria-current', Number(button.dataset.w4dGalPage) === page ? 'true' : 'false'); + } else { + var step = Number(button.dataset.w4dGalStep); + button.disabled = (page + step) < 1 || (page + step) > pages; + } + } + } + + paint(); + if (fresh) list.parentElement.appendChild(pager); + } + /* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 151장이라 (일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */ - console.log('[w4d] 주입분 로드'); + /* ★ 어느 판을 보고 있는지 콘솔에서 바로 알게 한다. `/s/` 는 5분 캐시라 "고쳤는데 + 안 된다" 의 대부분이 **옛 파일을 보고 있는 것**이었다 — 그걸 눈으로 가른다. + `__W4D_BUILD__` 는 patch_stay.py 가 굽는 시각(UPDATED_AT)으로 바꿔 박는다. */ + console.log('[w4d] 주입분 로드 · 빌드 __W4D_BUILD__'); try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); } + try { startReading(); } catch (err) { console.warn('[w4d] 군산 읽기 실패', err); } + try { + startSongsRail(); + if (window.MutationObserver) { + // ★ 판 하나 고르는 클릭(`setPlaying`)에는 안 걸린다 — track 노드가 그대로라 + // `.slider-viewport` 를 계속 물고 있다. 이 감시는 **하이드레이션 재렌더처럼 + // #songs 통째가 다시 그려질 때만** 다시 감싼다. + new MutationObserver(function () { startSongsRail(); }) + .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); + } + console.log('[w4d] 가요 다방 카로셀 — 화살표 + embla'); + } catch (err) { console.warn('[w4d] 가요 다방 카로셀 실패', err); } + try { + postcardMaker(); + if (window.MutationObserver) { + new MutationObserver(function () { postcardMaker(); }) + .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); + } + console.log('[w4d] 엽서 직접 쓰기'); + } catch (err) { console.warn('[w4d] 엽서 만들기 실패', err); } try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); } catch (err) { console.warn('[w4d] 카로셀 실패', err); } + try { + galleryPager(); + if (window.MutationObserver) { + // 재렌더가 <li> 를 다시 만들면 표시(data-w4d-gal-on)가 사라진다 — 다시 찍는다. + // 속성은 감시하지 않는다(childList 만) — 우리가 찍는 속성에 우리가 다시 깨어난다. + new MutationObserver(function () { galleryPager(); }) + .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); + } + console.log('[w4d] 사진 갤러리 — 넓은 화면 ' + GAL_PER_PAGE + '장씩'); + } catch (err) { console.warn('[w4d] 갤러리 쪽 넘김 실패', err); } + /* ⑩ 푸터 '만든 곳' 한 줄 — 렌더러에 **있는데** 이 목업이 문 번들에는 없다. + ★ 왜 주입인가 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 / 반영반영") + `patch_stay.py` 가 정적 HTML 에 같은 줄을 박아 두지만, 이 한 장이 물고 있는 번들은 + 2026-09-09 판이라 그 줄을 모른다 — 하이드레이션이 끝나는 순간 리액트가 **지운다** + (실측 2026-09-15: HTML 에는 있는데 화면에는 0건). 번들을 갈아 끼우면 풀리지만, + 그건 목업 전체를 최신 렌더러로 다시 그리는 일이라 이 한 장을 깨뜨릴 수 있다 + (AGENTS.md 함정 1). 그래서 렌더러에 없는 것들과 **같은 방식**으로 얹는다. + ★ 마크업은 `SiteFooter.tsx` 가 내보내는 것과 같다 — 번들을 갈아 끼우는 날 + 리액트가 그리는 줄과 겹치면 안 되므로, 이미 있으면 얹지 않는다. */ + function startMadeBy() { + var HREF = 'https://www.o2osolution.ai/'; + function put() { + var footer = document.querySelector('footer'); + if (!footer) return false; + if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다 + var box = footer.querySelector('.shell') || footer.firstElementChild || footer; + var p = document.createElement('p'); + p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-55'; + p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' + + 'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.'; + box.appendChild(p); + return true; + } + put(); + // 리액트가 #root 를 다시 그리면 사라진다 — 감시해서 다시 얹는다(플레이어와 같은 수법). + if (window.MutationObserver) { + new MutationObserver(function () { put(); }) + .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); + } + } + window.setTimeout(function () { try { watchLinks(); @@ -704,5 +2051,13 @@ startCatchphrase(); console.log('[w4d] 캐치프레이즈 ' + catchphrases().length + '개 · 7초마다 전환'); } catch (err) { console.warn('[w4d] 캐치프레이즈 실패', err); } + try { + startWeatherNote(); +console.log('[w4d] 날씨 문구 — 하늘 5 · 기온대 5, 케이스마다 다섯 벌 중 하나'); + } catch (err) { console.warn('[w4d] 날씨 문구 실패', err); } + try { + startMadeBy(); + console.log('[w4d] 푸터 — AI O2O · Web4Ai'); + } catch (err) { console.warn('[w4d] 푸터 표기 실패', err); } }, 1200); })(); diff --git a/solution/site/scripts/mockup/patch_stay.py b/solution/site/scripts/mockup/patch_stay.py index 28079aa..0eb43d8 100644 --- a/solution/site/scripts/mockup/patch_stay.py +++ b/solution/site/scripts/mockup/patch_stay.py @@ -14,6 +14,15 @@ from pathlib import Path SP = Path(__file__).parent +# ── 최종 업데이트 시각 ──────────────────────────────────────────────────────── +# 화면 아래 "최종 업데이트", head 의 dateModified, JSON-LD, payload 의 site.updatedAt · +# local.syncedAt 이 전부 이 한 값을 쓴다. 구운 원본에는 2026-09-09 가 박혀 있어, 문구·맛집을 +# 고친 뒤에도 화면에는 옛 날짜가 남았다 (2026-09-11 사장님: "이거 업뎃해야지"). +# ★ now() 를 쓰지 않는다 — 내용을 안 고치고 다시 구워도 날짜가 올라가 버린다. 구글은 lastmod 를 +# 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 그 필드를 무시한다(seo/directory.ts). +# 내용을 고친 날에 이 값을 손으로 올린다. +UPDATED_AT = "2026-09-14T07:42:00+00:00" + # ── 캐치프레이즈 100개 ──────────────────────────────────────────────────────── # 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 — # 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인. @@ -92,6 +101,92 @@ WEATHER = { "눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"], } +# ── 오늘의 날씨 문구 ────────────────────────────────────────────────────────── +# ★ 케이스마다 한 줄뿐이었다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로") +# 같은 하늘·같은 기온이면 늘 같은 문장이 떴다. 시연에서 새로고침해도 안 바뀐다. +# ★ 축을 둘로 갈라 둔 것은 그대로다(site-payload.ts WeatherSnapshot 주석) — +# 하늘은 **집 안에서** 뭘 할지, 기온은 **밖에서** 어디를 갈지다. 한 칸에 담으면 +# 5×5 스물다섯 벌을 적어야 한다. +# ★ 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳). +# 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다. +# ★ 하늘은 다섯이다. 렌더러는 넷(맑음·흐림·비·눈)으로만 가르므로 구름많음과 그 외 +# (안개·소나기·뇌우)가 `notes.흐림` 한 칸을 같이 본다 — 그 둘을 가르는 것은 inject.js ⑥ 다. +SKY_NOTES = { + "맑음": [ + "마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.", + "기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다.", + "볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다.", + "창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다.", + "해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다.", + ], + "구름많음": [ + "빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.", + "그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다.", + "해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다.", + "구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다.", + "빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다.", + ], + "그외": [ + "해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다.", + "하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다.", + "안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다.", + "소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다.", + "바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다.", + ], + "비": [ + "백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.", + "비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다.", + "마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요.", + "실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다.", + "우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다.", + ], + "눈": [ + "기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다.", + "눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다.", + "눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다.", + "디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요.", + "창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다.", + ], +} +# 기온대 경계는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`) — 30·25·20·10. +TEMP_NOTES = { + "혹서": [ + "낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.", + "한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요.", + "옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다.", + "경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다.", + "물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다.", + ], + "더움": [ + "선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.", + "낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다.", + "우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다.", + "해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다.", + "가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요.", + ], + "선선": [ + "걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.", + "원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다.", + "차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다.", + "해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다.", + "낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다.", + ], + "쌀쌀": [ + "월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.", + "겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다.", + "실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다.", + "따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요.", + "오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요.", + ], + "추움": [ + "밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다.", + "항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요.", + "장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다.", + "따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다.", + "일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다.", + ], +} + items = [{"text": t, "kind": "general"} for t in GENERAL] items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst] items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst] @@ -99,6 +194,71 @@ items += [{"text": t, "kind": "weather", "weather": w} for w, lst in WEATHER.ite payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8")) payload["narrative"]["catchphrases"] = {"version": 1, "items": items} + +# ── '오늘의 엽서' 탭을 없앤다 ───────────────────────────────────────────────── +# ★ 왜 (2026-09-14 대표: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련 +# 엽서를 쓰는 걸로 하자") — 도시 엽서 4장은 이제 안 쓴다. 대신 손님이 직접 쓰는 +# 엽서 섹션을 새로 만들고(inject.js `postcardMaker`), 그건 '군산 이야기' 탭이 +# 아니라 `#location`–`#festival` 사이의 **독립 섹션**이다. +# ★ 마크업을 안 건드리고 payload 만 비운다 — `StorySection.tsx` 는 +# `sectionItems(payload,'postcard').items.length > 0` 인 탭만 세우므로, data 를 +# 비우면 렌더러 코드는 그대로 두고도 탭이 저절로 사라진다(README 2.6 의 원칙과 같다: +# "마크업을 고쳐 지우지 않는다"). +for _sec in payload["theme"]["sections"]: + if _sec["id"] == "postcard": + _sec["data"] = json.dumps({"kind": "postcard", "version": 1, "items": []}, ensure_ascii=False) + break + +# 굽는 판에는 케이스마다 **첫 문장**을 박는다 — 크롤러와 첫 화면이 보는 값이고, +# inject.js 가 DOM 에서 갈아 끼울 때 찾는 닻이기도 하다. 문장이 겹치면 엉뚱한 줄을 바꾼다. +_all_notes = [t for lst in SKY_NOTES.values() for t in lst] + \ + [t for lst in TEMP_NOTES.values() for t in lst] +assert len(_all_notes) == len(set(_all_notes)), "날씨 문구가 겹친다 — DOM 에서 어느 줄인지 못 가른다" +payload["local"]["weather"].update({ + # 렌더러가 아는 넷. 구름많음은 '흐림' 칸을 쓴다(그 외와 가르는 것은 inject.js ⑦). + "notes": {"맑음": SKY_NOTES["맑음"][0], "흐림": SKY_NOTES["구름많음"][0], + "비": SKY_NOTES["비"][0], "눈": SKY_NOTES["눈"][0]}, + "tempNotes": {band: lines[0] for band, lines in TEMP_NOTES.items()}, + # 렌더러가 모르는 칸이다(계약에 없어 그대로 지나간다). 주입분만 읽는다. + "noteSets": SKY_NOTES, + "tempNoteSets": TEMP_NOTES, +}) + +# ── 주변 맛집 8곳 → 2km 안 31곳 ────────────────────────────────────────────── +# ★ 원본 8곳이 전부 467m 안이라 '걸어서 10분 이상' 탭에서 맛집 칸이 비었다 (2026-09-11 사장님: +# "주변맛집 데이터가 너무 적은거같아"). 목록은 fetch_restaurants.py 가 TourAPI 에서 받아 둔 것이다 — +# 여기서 API 를 부르지 않는다. 굽기가 네트워크·키에 매이면 다른 기계에서 못 굽는다. +payload["local"]["restaurants"] = json.loads((SP / "restaurants.json").read_text(encoding="utf-8")) +# 사진 없는 집에 시연본 전용 사진을 덧댄다(fill_restaurant_photos.py — 공공누리 아님, 제품으로 옮기지 않는다). +# TourAPI 사진이 있는 집은 건드리지 않는다. +_photos = json.loads((SP / "restaurant-photos.json").read_text(encoding="utf-8")) +for _r in payload["local"]["restaurants"]: + if not _r.get("imageUrl") and _photos.get(_r["name"], {}).get("file"): + _r["imageUrl"] = f"/assets/mirror/{_photos[_r['name']]['file']}" +# ── 군산 국가유산 야행 ─────────────────────────────────────────────────────── +# ★ 왜 (2026-09-14 대표: 받은 `gunsan_365_story_db.xlsx` 52주제 대조 — 사이트에 없던 둘 중 하나) +# 나머지 축제는 TourAPI 가 준 것이라 이 목록에 없었다. 원도심 국가유산을 밤에 여는 +# 행사라 머뭄(신흥동)에서 걸어 닿는 범위이고, 여름 저녁 일정과 맞는다. +# ★ **이름은 '문화재야행' 이 아니다.** 엑셀에는 옛 이름으로 적혀 있는데, 2024년 문화재청이 +# 국가유산청으로 바뀌면서 행사명도 '국가유산 야행' 으로 갈렸다(군산시 문화예술과 공지 확인). +# 옛 이름으로 두면 검색 링크가 엉뚱한 해의 문서로 간다. +# ★ 날짜(period·startDate)는 **적지 않는다.** 확인한 공지는 2025년분이고 2026년 일정은 +# 아직 공고 전이다 — 지어내지 않는다(레포 절대 규칙). 축제 10개 중 5개가 이미 날짜 없이 +# 서 있어 화면도 그 경우를 감당한다. +payload["local"]["festivals"].append({ + "name": "군산 국가유산 야행", + "month": "8월", + "season": "여름", + "location": "구 조선식량영단 등 원도심 국가유산 일원 · 걸어서 20분", + "description": "원도심의 국가유산을 밤에 여는 행사다. 야경·야로·야사 등 여덟 갈래로 나뉘어 " + "신흥동 일본식 가옥의 정원 조명, 스탬프 투어, 옛 건물에서 여는 공연과 야시장이 " + "저녁부터 밤까지 이어진다.", + "searchQuery": "군산 국가유산 야행", +}) + +payload["site"]["updatedAt"] = UPDATED_AT +# 주변 정보(맛집·명소)를 갈아 끼운 시각도 같다 — 이 스크립트가 그 둘을 같이 바꾼다. +payload["local"]["syncedAt"] = UPDATED_AT (SP / "stay-payload-new.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") # ── 머뭄이 만든 노래 5곡 ────────────────────────────────────────────────────── @@ -234,6 +394,35 @@ for unit in payload["units"]: # 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다. unit["mediaIds"] = per_unit.get(room, []) +# ── 화면에서 뺄 탭 ─────────────────────────────────────────────────────────── +# ★ `enabled = False` 로는 안 꺼진다 (2026-09-14 사장님: "오늘의 한장은 주석처리해주쇼") +# 이 다섯은 '군산 이야기' 안의 탭이고, 탭은 **항목이 있는 것만** 선다(StorySection.tsx:41). +# enabled 는 아예 보지 않는다 — 실측: daily 가 enabled=False 인데도 탭이 서 있었다. +# ★ 그래서 항목을 비운다. 원값은 stay-payload-new.json 에 그대로 있으므로(위에서 이미 썼다) +# 되살리려면 아래 집합에서 id 만 빼면 된다. 다시 만들 필요 없다. +HIDE_TABS = {"daily"} # 오늘의 한 장 + +for _section in payload["theme"]["sections"]: + if _section["id"] not in HIDE_TABS: + continue + _env = json.loads(_section["data"]) + _env["items"] = [] + _section["data"] = json.dumps(_env, ensure_ascii=False) + _section["enabled"] = False + +# ── 예약 안내를 켠다 ───────────────────────────────────────────────────────── +# ★ 왜 (2026-09-14 사장님: "예약 섹션을 안 가져다가 렌더링하네, 그거 구현되었으니 가져오셈") +# 원본 payload 의 booking 은 enabled=False 였다. 숙박이면 이 자리는 요금·인원·창구에 +# 날짜/시간 목업까지 붙은 `StayBookingSection` 이 그린다(HomePage.tsx:69) — 회의록의 +# "예약 페이지는 연동이 어려워 우선 목업 형태로" 가 이미 구현돼 있던 것이다. +# ★ 기본값(항목이 없으면 알아서 나가는 HomePage.tsx:122)에 기대지 못한다 — 항목이 +# **있는데 꺼진** 상태라 `hasSection()` 이 참이고, 그 분기는 "사장님이 끈 것" 으로 존중한다. +# ★ 지어낸 값이 없다. 요금·인원·취소 규정은 payload 의 확인된 fact 뿐이고 +# (`derive.ts stayBookingView`), 근거가 하나도 없으면 섹션째 안 그린다. +for _section in payload["theme"]["sections"]: + if _section["id"] == "booking": + _section["enabled"] = True + # ── index.html 조립 ────────────────────────────────────────────────────────── html = (SP / "orig" / "index.html").read_text(encoding="utf-8") @@ -244,6 +433,31 @@ html, n = re.subn(r"<script>window\.__SITE_PAYLOAD__=.*?</script>", html, count=1, flags=re.S) assert n == 1, "payload 스크립트를 못 찾았다" +# ★ 날짜는 payload 밖에도 박혀 있다 — head 의 dateModified · article:modified_time · +# JSON-LD · 화면의 <time>. payload 만 고치면 **크롤러와 첫 화면은 옛 날짜를 본다** +# (React 가 다시 그리기 전까지, 그리고 JS 를 안 돌리는 크롤러에게는 영영). +OLD_UPDATED_AT = "2026-09-09T13:40:00+00:00" +html = html.replace(OLD_UPDATED_AT, UPDATED_AT) +html = html.replace(f">{OLD_UPDATED_AT[:10]}</time>", f">{UPDATED_AT[:10]}</time>") +assert OLD_UPDATED_AT not in html and OLD_UPDATED_AT[:10] not in html, "옛 날짜가 남았다" + +# ── 푸터 '만든 곳' 한 줄 ────────────────────────────────────────────────────── +# ★ 왜 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 목업 빌더 다 똑같아") +# 렌더러가 박는 줄이라(`SiteFooter.tsx`) 굽는 사이트에는 전부 들어가는데, 이 한 장은 +# 2026-09-09 판 프리렌더 결과물(`orig/index.html`)이라 그 줄이 없다. +# ★ 마크업은 렌더러가 내보내는 것과 **글자 하나까지 같아야** 한다 — 정적 HTML 위에 +# 하이드레이션이 얹히므로(entry-client.tsx) 어긋나면 React 가 그 자리를 다시 그린다. +# 위 문자열은 `render(payload)` 출력에서 그대로 떠 온 것이다(속성 순서까지). +# ★ 화면이 실제로 따라오는 것은 `vendor/` 번들을 다시 복사한 다음이다(아래 ★ 갱신 절차) — +# 지금 넣는 것은 크롤러와 첫 화면이 보는 값이고, 날짜를 payload 밖에서도 고치는 것과 +# 같은 이유다. +MADE_BY = ('<p class="pt-3 text-[length:var(--fs-xs)] opacity-55">' + '<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer" ' + 'class="underline-offset-2 hover:underline">AI O2O</a>' + '의 Web4Ai로 만든 사이트입니다.</p>') +html, n = re.subn(r"</div></footer>", lambda _m: MADE_BY + "</div></footer>", html, count=1) +assert n == 1, "푸터 끝(</div></footer>)을 못 찾았다" + # ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시). # 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다. # 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다. @@ -252,9 +466,23 @@ assert n == 1, "payload 스크립트를 못 찾았다" css = (SP / "inject.css").read_text(encoding="utf-8") js = (SP / "inject.js").read_text(encoding="utf-8") +# 빌드 도장 — 콘솔에서 "지금 보는 게 언제 구운 것인가"를 바로 읽게 한다(위 ★ 주석, inject.js). +assert "__W4D_BUILD__" in js, "inject.js 에서 빌드 도장 자리가 사라졌다" +js = js.replace("__W4D_BUILD__", UPDATED_AT) assert "</style>" not in css and "</script>" not in js -block = ("\n <style id=\"w4d-css\">\n" + css + "\n </style>\n" +# ★ 군산 읽기 카로셀은 이 사이트가 실제 쓰는 그 라이브러리(embla-carousel)로 움직인다 +# (2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해" — 맞는 말이다). +# `inject.js` 는 React 번들 밖의 순정 스크립트라 앱이 물고 있는 embla 인스턴스를 가져다 +# 쓸 수 없다 — 그래서 **코어(라이브러리 자체, React 래퍼 없이)의 UMD 빌드**를 이 목업 +# 전용으로 하나 더 들여왔다. 의존성 0 · 17.9KB(비압축, 사이트 자체 주석의 "~5KB gzip"과 +# 같은 값) — `node_modules/embla-carousel/embla-carousel.umd.js` 를 그대로 복사한 것이고 +# 손으로 고치지 않는다(라이선스 MIT, 버전은 site/package.json 의 embla-carousel-react 와 맞춘다). +assert (SP / "vendor" / "embla-carousel.umd.js").exists(), \ + "vendor/embla-carousel.umd.js 가 없다 — node_modules/embla-carousel/embla-carousel.umd.js 를 복사한다" + +block = ("\n <script src=\"/s/stay/vendor/embla-carousel.umd.js\"></script>\n" + " <style id=\"w4d-css\">\n" + css + "\n </style>\n" " <script id=\"w4d-js\">\n" + js + "\n </script>\n ") # 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다. html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1) @@ -270,6 +498,28 @@ assert n == 1, "</body> 를 못 찾았다" # 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다. # 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다. # (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.) +# ★ 번들은 **레포에서 지금 구운 것**으로 갈아 끼운다 — `orig/index.html` 에 박힌 해시는 +# 2026-09-09 판이라 그 뒤에 들어온 화면이 없다(실측: 숙박 예약 섹션 `StayBookingSection` +# 이 그 번들에 아예 없어서, payload 로 켜도 화면에 아무것도 안 그려졌다). +# 목업은 자기 번들을 `vendor/` 에 들고 다니므로(아래 주석) 해시를 맞출 상대가 없다 — +# `vendor/` 에 있는 한 벌이 정답이고, 원본의 옛 이름을 그 이름으로 바꾼다. +# 갱신 절차: `solution/site` 에서 `npm run build:client` → `dist/client/assets/index-*.{js,css}` +# 를 `vendor/` 로 복사하고 옛 파일을 지운다. (여기는 이름만 맞추므로 손댈 것이 없다.) +# ★ `<body class="site">` — 새 번들에서는 이게 없으면 35줄이 조용히 죽는다. +# 2026-09-09 의 `b1386dd`(에디터와 발행본을 한 렌더러로) 이후 발행본 공용 스타일이 +# `:where(.site,.site-canvas) …` 로 스코프됐다. `orig/index.html` 은 그 전에 구운 것이라 +# body 에 클래스가 없다 — 실측: 카로셀 상자(`.slider-viewport`)의 `overflow-x` 가 통째로 +# 빠져 모바일에서 판 25장이 화면 밖으로 흘러 **가로 스크롤이 생겼다.** +# 프리렌더가 지금 내보내는 것과 같은 마크업으로 맞춘다(`scripts/prerender.ts:384`). +html, n = re.subn(r"<body(?![^>]*\bclass=)", '<body class="site"', html, count=1) if True else (html,1) +assert n == 1 or 'class="site"' in html, "body 에 site 클래스를 못 붙였다" + +_vendor = {ext: sorted((SP / "vendor").glob(f"index-*.{ext}")) for ext in ("js", "css")} +for _ext, _found in _vendor.items(): + assert len(_found) == 1, f"vendor/ 의 {_ext} 번들이 {len(_found)}개다 — 한 벌만 둔다: {_found}" +html = re.sub(r"/assets/index-[\w.-]+\.js", "/assets/" + _vendor["js"][0].name, html) +html = re.sub(r"/assets/index-[\w.-]+\.css", "/assets/" + _vendor["css"][0].name, html) + missing = [] for name in sorted(set(re.findall(r"/assets/mirror/[0-9a-f]+\.\w+", html))): if not (SP / "img" / "mirror" / name.rsplit("/", 1)[1]).exists(): @@ -289,6 +539,7 @@ out.mkdir(exist_ok=True) (out / "index.html").write_text(html, encoding="utf-8") env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"]) -print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개") +print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개 " + f"· 맛집 {len(payload['local']['restaurants'])}곳") print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}" for k in ("general", "season", "month", "weather"))) diff --git a/solution/site/scripts/mockup/restaurant-photos.json b/solution/site/scripts/mockup/restaurant-photos.json new file mode 100644 index 0000000..a432237 --- /dev/null +++ b/solution/site/scripts/mockup/restaurant-photos.json @@ -0,0 +1,57 @@ +{ + "군산복집": { + "url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=07337813-0fb6-4e60-a6c7-e130feff5a63", + "title": "(인쇄용) 군산복집 | 대한민국 구석구석", + "file": "067f6fe42c045a3a.jpg" + }, + "진갈비": { + "url": "http://www.gunsan.go.kr/upload_data/board_data/BBS_0000054/140229349431468.jpg", + "title": "음식,숙박,쇼핑 > 음식점 > 군산맛집", + "file": "92cb907d07db9953.jpg" + }, + "한주옥": { + "url": "https://d12zq4w4guyljn.cloudfront.net/20220816093444324_photo_3w9NMDVGF1oN.webp", + "title": "한주옥 - 군산 간장게장, 꽃게장정식 맛집 - 다이닝코드", + "file": "f9cb7e88415edd06.jpg" + }, + "사골뚝배기": { + "url": "https://www.gunsan.go.kr/upload_data/board_data/FOOD/150838840634244.jpg", + "title": "게시글 목록 < 탕류 < 음식 < 음식/숙박/쇼핑 < 문화관광", + "file": "24043ae4f5854f96.jpg" + }, + "스위트인디아": { + "url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=bd1e6e61-70f2-462d-a8f7-f7fc31d98443", + "title": "스위트인디아> 여행지 :대한민국 구석구석 스위트인디아> 여행지 :대한민국 구석구석 ", + "file": "bf6bb6183c6f6246.jpg" + }, + "홍영장": { + "url": "https://pup-post-phinf.pstatic.net/MjAyNjA1MjNfMTYg/MDAxNzc5NTE2OTMxNDkz.SRqGW5cr3uqRcOaulY6pgfkM_b8UPyxL1QwZ_WN-bfUg.tDWdfAwPxIUmrComXVMgo-hSslTYtbmI3x3iQyG0XKAg.JPEG/PostEncodingTask.30343AE4-7E4E-47B8-A9C0-978AD991D374.jpg", + "title": "물짜장 맛집입니더!!! #가정의달 #오늘클립챌린지 #군산 #홍영장 #맛집", + "file": "087900c82da3b2f5.jpg" + }, + "명동소바": { + "url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=26a5546a-6966-4391-b0eb-46703f24adb9", + "title": "명동소바> 여행지 :대한민국 구석구석 명동소바> 여행지 :대한민국 구석구석 ", + "file": "55f57f45fcebb460.jpg" + }, + "유락식당": { + "url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=911b5e60-01e0-4eab-8579-4cd447b94dca", + "title": "(인쇄용) [백년가게]유락식당 | 대한민국 구석구석", + "file": "5f92192c7b3e2cbf.jpg" + }, + "일풍식당": { + "url": "https://ldb-phinf.pstatic.net/20200103_164/1578048423036vc5I1_JPEG/lIOOzZx3sZZK9JUJgMsD3dHg.jpg", + "title": "일풍식당", + "file": "e13c8a2a1aa2762c.jpg" + }, + "아리랑": { + "url": "https://pup-post-phinf.pstatic.net/MjAyNjA2MDhfNCAg/MDAxNzgwODg2MDI5NTY1.6vNfC_2hbbyXSjtxSEDSTzkn0f3j8rrEjU15_2DL2Kkg.dbYhyKh4ozdjjWXUUS-oYnCJp7-oFfh_2bU0oc1OBAog.JPEG/POST_IMAGE_ENC_20260608_113317_278.jpg", + "title": "#오늘클립챌린지#아리랑식당#비빔밥정식#군산맛집", + "file": "1302460cb149975f.jpg" + }, + "국일식당": { + "url": "https://ldb-phinf.pstatic.net/20240406_119/1712415338890jfJDL_JPEG/IMG_3315.jpeg", + "title": "국일복아구", + "file": "6102b164464dbfa4.jpg" + } +} diff --git a/solution/site/scripts/mockup/restaurants.json b/solution/site/scripts/mockup/restaurants.json new file mode 100644 index 0000000..6d4b068 --- /dev/null +++ b/solution/site/scripts/mockup/restaurants.json @@ -0,0 +1,239 @@ +[ + { + "name": "도란", + "category": "맛집", + "searchQuery": "도란", + "distanceText": "135m", + "imageUrl": "/assets/mirror/12938240987c1f78.jpg", + "description": "전라북도 군산시 월명동에 있는 요리주점이다. 와인과 함께 카페와 양식을 메뉴를 주로 다루고 있다. 대표 메뉴로는 감바스, 해산물 크림 스튜, 부채살 스테이크 등이 있다." + }, + { + "name": "물밀소", + "category": "맛집", + "searchQuery": "물밀소", + "distanceText": "297m", + "imageUrl": "/assets/mirror/07600bb67b91dd15.jpg", + "description": "월명동에 자리한 베이커리 물밀소는 신생 빵집으로 입소문이 자자하다. 붉은 벽돌의 아담한 외관을 가졌고 매장 내부에는 저온숙성한 반죽으로 빚어낸 건강한 빵들이 진열돼 있다." + }, + { + "name": "일흥옥", + "category": "맛집", + "searchQuery": "일흥옥", + "distanceText": "300m", + "imageUrl": "/assets/mirror/3420ff7e357ab7ef.jpg", + "description": "일흥옥은 군산 테디베어뮤지엄 인근에 위치한 국밥 전문점이다. 중소벤처기업부로부터 백년가게 인증을 받은 곳이다." + }, + { + "name": "군산복집", + "category": "맛집", + "searchQuery": "군산복집", + "distanceText": "305m", + "description": "군산복집은 싱싱한 재료만을 사용하는 복요리 전문점으로 복탕과 아구탕은 해장하기에 좋으며, 오래된 전통과 경력의 주방장이 내는 회 맛이 일품인 곳이다." + }, + { + "name": "명월갈비", + "category": "맛집", + "searchQuery": "명월갈비", + "distanceText": "351m", + "imageUrl": "/assets/mirror/881d1f7ff820942f.jpg", + "description": "군산 신창동에 있는 명월갈비는 군산 지역의 대표 양념 소갈비 전문 맛집이다. 플러스 등급의 한우 갈비를 비법 양념장으로 숙성한 단품 메뉴로 오랜 기간 동안 인기를 유지해오고 있다." + }, + { + "name": "만남스넥", + "category": "맛집", + "searchQuery": "만남스넥", + "distanceText": "405m", + "imageUrl": "/assets/mirror/d2be5a053a944cfe.jpg", + "description": "만남스넥은 군산 현지인들이 좋아하는 메뉴인 잡탕을 파는 분식집이다. 잡탕은 떡, 만두, 라면, 어묵 등을 넣고 같이 끓인 메뉴인데, 떡볶이나 라볶이보다 국물이 많고 부재료가 풍부해 잡탕으로 불리게…" + }, + { + "name": "동국사다온", + "category": "맛집", + "searchQuery": "동국사다온", + "distanceText": "418m", + "imageUrl": "/assets/mirror/4db345bd2cb4f0d7.jpg", + "description": "전라북도 군산시 일본식 사찰인 동국사 사찰 내에 있는 테이크아웃 커피 전문점이다. 불교 용품과 함께 대추차, 오미자차, 오룡차 등 다양한 차 종류를 판매하고 있다." + }, + { + "name": "진갈비", + "category": "맛집", + "searchQuery": "진갈비", + "distanceText": "426m", + "description": "소박해보이지만 오랜 세월의 내공으로 묵직하고 깊은 떡갈비를 선사하는 맛집이다. 군산 현지인들이 추천하는 유명 맛집이기도 하다." + }, + { + "name": "한주옥", + "category": "맛집", + "searchQuery": "한주옥", + "distanceText": "430m", + "description": "‘한주옥’은 전북 군산시 영화동에 위치한 꽃게장 전문 음식점이다. 꽃게장 백반/정식, 대하장 백반/정식을 맛볼 수 있다." + }, + { + "name": "이성당", + "category": "맛집", + "searchQuery": "이성당", + "distanceText": "455m", + "imageUrl": "/assets/mirror/37c90494d28478e2.png", + "description": "일본 시마네현 이즈모시에 살다가 1906년 조선으로 건너온 히로세 야스타로라는 일본인이 ‘이즈모야’라는 이름으로 문을 열어 영업한 것이 시초이다." + }, + { + "name": "국제반점", + "category": "맛집", + "searchQuery": "국제반점", + "distanceText": "467m", + "imageUrl": "/assets/mirror/149c18951bdcaff8.jpg", + "description": "군산 영화동에 있는 국제반점은 1960년대 초 진흥반점으로 개업해 현재는 상호를 국제 반점으로 변경하여 성업 중인 중식당이다. 영화 <타짜>에 등장해 군산의 관광 명소가 되었다." + }, + { + "name": "사골뚝배기", + "category": "맛집", + "searchQuery": "사골뚝배기", + "distanceText": "479m", + "description": "사골 뚝배기는 전북 군산시에 위치한 한식 전문점이다. 한우고기와 한우사골만을 써서 아주 진하고 맛깔스러운 국물을 우려내어 요리를 만든다." + }, + { + "name": "영화원", + "category": "맛집", + "searchQuery": "영화원", + "distanceText": "495m", + "imageUrl": "/assets/mirror/42e8d872198edaa9.jpg", + "description": "군산 영화동에 있는 영화원은 1976년부터 지금까지 4대째 운영하는 오래된 중국 음식점이다. 대를 이어오면서 그 맛의 비결을 간직하고 있다." + }, + { + "name": "스위트인디아", + "category": "맛집", + "searchQuery": "스위트인디아", + "distanceText": "539m", + "description": "전라북도 군산시에 위치한 스위트인디아는 인도 카레 맛집으로, 양고기, 소고기, 닭고기, 새우, 야채 총 5가지의 맛의 카레를 판매하고 있다." + }, + { + "name": "틈(TEUM)", + "category": "맛집", + "searchQuery": "틈(TEUM)", + "distanceText": "573m", + "imageUrl": "/assets/mirror/81bd76fa51ae9ad1.jpg", + "description": "군산항 근처에 있는 옛 미곡 창고를 그대로 활용한 카페다. ‘틈’이란 이름처럼 입구가 좁은 골목길 틈에 위치해 있어 간판을 눈여겨보지 않으면 찾기 어렵다." + }, + { + "name": "미즈커피", + "category": "맛집", + "searchQuery": "미즈커피", + "distanceText": "696m", + "imageUrl": "/assets/mirror/3e5b3daae33ea51c.jpg", + "description": "군산 근대문화 역사관 바로 옆에 자리한 카페로 일제강점기 무역회사였던 ‘미즈 상사’의 옛 사옥을 그대로 활용했다. 당시 일본인이 운영했던 미즈 상사는 식료품과 잡화 등을 수입해 판매하던 회사였다." + }, + { + "name": "홍영장", + "category": "맛집", + "searchQuery": "홍영장", + "distanceText": "725m", + "description": "홍영장은 군산의 짬뽕거리에 위치한 중식당으로 방송에서 인정받은 맛집이다. 평소에 흔히 맛볼 수 없는 메뉴인 물짜장이 유명하다." + }, + { + "name": "아리랑", + "category": "맛집", + "searchQuery": "아리랑", + "distanceText": "732m", + "description": "군산에 위치한 향토음식점으로 군산 지역의 특산물을 맛볼 수 있는 음식점이다." + }, + { + "name": "운정식당", + "category": "맛집", + "searchQuery": "운정식당", + "distanceText": "748m", + "imageUrl": "/assets/mirror/00a8ac4ce017fb12.jpg", + "description": "군산 개복동 골목의 터줏대감으로 40년 가까이 한 자리를 지키고 있는 식당이다." + }, + { + "name": "빈해원", + "category": "맛집", + "searchQuery": "빈해원", + "distanceText": "749m", + "imageUrl": "/assets/mirror/483f52c2d2b7eebe.jpg", + "description": "군산 빈해원은 1965년 콘크리트와 벽돌을 사용하여 지은 2층 건물이다. 이 건물은 1~2층이 개방된 내부공간과 각층에 여러 개의 방이 있는 독특한 구조이다." + }, + { + "name": "국일식당", + "category": "맛집", + "searchQuery": "국일식당", + "distanceText": "935m", + "description": "전라북도 군산시에 위치한 국일식당은 콩나물의 아삭함과 매콤한 양념이 어우러진 아귀찜 전문점이다. 군산의 전통명가 답게 모범 착한업소로 선정된 곳이다." + }, + { + "name": "명동소바", + "category": "맛집", + "searchQuery": "명동소바", + "distanceText": "961m", + "description": "‘군산명동소바’는 30년 이상된 군산 최초 원조 소바집이다. 메밀면발도 직접 연구 개발하고 매장 자체적으로 기계로 면을 빼서 쫄깃하고 탱탱한 맛으로 현지인뿐만 아니라 여행객의 많은 발길이 이어진다." + }, + { + "name": "복성루", + "category": "맛집", + "searchQuery": "복성루", + "distanceText": "1.3km", + "imageUrl": "/assets/mirror/b3518ee8ef2b4aac.png", + "description": "복성루는 빈해원 지린성과 함께 군산 3대 짬뽕 맛집으로 유명하며 싱싱한 오징어, 홍합, 꼬막 등 다양한 해산물과 야채를 넣고 우려낸 진한 짬뽕 국물이 일품인 가게로 줄 서서 맛보는 지역의 대표 짬뽕집으로 자리 잡고 있다." + }, + { + "name": "유락식당", + "category": "맛집", + "searchQuery": "유락식당", + "distanceText": "1.3km", + "description": "김정례 대표가 1981년 문을 열어 40여 년 동안 지역을 대표하는 음식점으로 손님에게 최선의 맛과 서비스를 제공하고자 하는 곳이다." + }, + { + "name": "왕산반점", + "category": "맛집", + "searchQuery": "왕산반점", + "distanceText": "1.5km", + "imageUrl": "/assets/mirror/aebe437fd281f9ad.jpg", + "description": "특허받은 콩나물 짬뽕으로 유명한 중화요리 집이다. 짬뽕면 위에 푸짐하게 올라간 콩나물, 홍합, 그 위에 신선한 낙지 한 마리까지 먹기 전부터 시각을 자극한다." + }, + { + "name": "리투스카페", + "category": "맛집", + "searchQuery": "리투스카페", + "distanceText": "1.6km", + "imageUrl": "/assets/mirror/81438670a5485351.jpg", + "description": "리투스카페는 군산 내항 부두 근처에 있는 오션 뷰 카페다. 특히 황금빛 일몰이 아름다워 일몰 카페로도 유명하다." + }, + { + "name": "일풍식당", + "category": "맛집", + "searchQuery": "일풍식당", + "distanceText": "1.6km", + "description": "‘일풍식당’은 각종 TV매체에서 맛집으로 다룬 곳이다. 골목 사이에 위치해 있어 찾을 때 약간 주의를 해야 한다." + }, + { + "name": "수송반점", + "category": "맛집", + "searchQuery": "수송반점", + "distanceText": "1.7km", + "imageUrl": "/assets/mirror/98539e6361e317b1.jpg", + "description": "수송 반점은 군산시 서흥남동, 서흥 중학교 근처에 있는 중식당이다." + }, + { + "name": "군산활어회센타", + "category": "맛집", + "searchQuery": "군산활어회센타", + "distanceText": "1.9km", + "imageUrl": "/assets/mirror/93146c8ae53252ff.jpg", + "description": "군산 문화동에 있는 '군산 활어회센터'는 가까이 있는 군산항 활어회 직판장에서 가져오는 활어회로 신선도와 식감 면에서 뛰어나다." + }, + { + "name": "진성원", + "category": "맛집", + "searchQuery": "진성원", + "distanceText": "1.9km", + "imageUrl": "/assets/mirror/83766fa52745ef37.jpg", + "description": "진성원은 전라북도 군산시 경암동에 있는 중식당이다." + }, + { + "name": "서우식당", + "category": "맛집", + "searchQuery": "서우식당", + "distanceText": "2.0km", + "imageUrl": "/assets/mirror/de286714af7faaba.jpg", + "description": "서우식당은 저렴한 가격에 푸짐한 한상차림으로 허영만의 백반 기행 등 TV 프로그램에도 소개된 한식집이다." + } +] diff --git a/solution/site/scripts/mockup/stay-payload-new.json b/solution/site/scripts/mockup/stay-payload-new.json index 11ba0f8..b42baad 100644 --- a/solution/site/scripts/mockup/stay-payload-new.json +++ b/solution/site/scripts/mockup/stay-payload-new.json @@ -1 +1 @@ -{"schemaVersion": 1, "site": {"siteId": "e929e6b4-e502-4761-9786-0bb82178b375", "placeId": "66894a1b-a331-43f1-942c-0ae064a704d6", "status": 3, "origin": "https://web4ai.o2osolution.ai", "basePath": "/s/stay", "slug": "stay", "publishedAt": "2026-09-03T01:27:39.284442+00:00", "updatedAt": "2026-09-09T13:40:00+00:00", "version": 11}, "place": {"name": "스테이,머뭄", "category": 1, "roadAddress": "전북 군산시 절골길 18", "address": "전북 군산시 신흥동 63-18", "addressRegion": "전북", "addressLocality": "군산시", "addressSubLocality": null, "addressRegionCode": "KR-45", "phone": "0507-1497-0983", "latitude": 35.9864175, "longitude": 126.7061254, "regionCode": "52군산시", "kakaoPlaceId": null}, "facts": [{"key": "parking", "label": "주차 가능", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.678003+00:00", "verifiedAt": "2026-08-28T02:12:21.402109+00:00"}, {"key": "intro", "label": "숙소 소개", "value": "1920년대에 지어진 백 년 고택을 리모델링한 독채 펜션입니다. 히로쓰 가옥 후문 바로 옆이고, 침구는 매일 세탁·살균·건조합니다.", "unit": null, "type": "text", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.547669+00:00", "verifiedAt": "2026-08-28T02:12:21.922925+00:00"}, {"key": "wifi", "label": "와이파이", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.757070+00:00", "verifiedAt": "2026-08-28T02:12:22.356600+00:00"}, {"key": "check_out_time", "label": "체크아웃 시간", "value": "11:00", "unit": null, "type": "time", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "cancel_policy", "label": "취소·환불 규정", "value": "입실 당일 취소와 노쇼(No Show)는 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", "unit": null, "type": "text", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "cooking_allowed", "label": "취사 가능", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "pet_allowed", "label": "반려동물 동반", "value": "false", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "smoking", "label": "흡연 가능", "value": "false", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "extra_person_fee", "label": "인원 추가 요금", "value": "20000", "unit": "원", "type": "number", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "check_in_time", "label": "체크인 시간", "value": "15:00", "unit": null, "type": "time", "scope": "place", "status": 3, "sourceType": 1, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T23:31:05.719599+00:00"}, {"key": "pickup_service", "label": "픽업 서비스", "value": "false", "type": "bool", "scope": "place", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}, {"key": "bbq_available", "label": "바비큐 이용", "value": "false", "type": "bool", "scope": "place", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "units": [{"unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "name": "A동(일본식 가정집 느낌으로 디딤돌마당과 예쁜정원)", "slug": "a동", "sortOrder": 0, "facts": [{"key": "room_type", "label": "객실 타입", "value": "독채", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "standard_capacity", "label": "기준 인원", "value": "2", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "max_capacity", "label": "최대 인원", "value": "4", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "weekday_price", "label": "주중 요금", "value": "198000", "unit": "원", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "room_intro", "label": "객실 소개", "value": "편안한 일본집에 와 있는 듯한 느낌의 공간입니다. 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다.", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "price_range", "label": "요금", "value": "198,000 ~ 350,000원", "type": "text", "scope": "unit", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": true, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "mediaIds": ["1330beb8-09d3-47fc-a410-f2e9598d13fe", "ad35769c-5299-4d39-b80d-5d88f1e1de14", "2fbcd37e-260f-4a4f-b00f-3e997e978c02", "d95857a0-44ad-422b-86de-00dee8aca08f"]}, {"unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "name": "B동(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)", "slug": "b동", "sortOrder": 1, "facts": [{"key": "room_type", "label": "객실 타입", "value": "독채", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "standard_capacity", "label": "기준 인원", "value": "2", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "max_capacity", "label": "최대 인원", "value": "4", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "weekday_price", "label": "주중 요금", "value": "198000", "unit": "원", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "room_intro", "label": "객실 소개", "value": "아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다.", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "price_range", "label": "요금", "value": "198,000 ~ 350,000원", "type": "text", "scope": "unit", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": true, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "mediaIds": ["5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", "4981b572-10e2-4138-ba1d-a5d7defb4b4a", "83a2543f-4366-4427-b694-a7b1968a61cf"]}], "media": [{"mediaId": "1330beb8-09d3-47fc-a410-f2e9598d13fe", "url": "/assets/mirror/ddc9f804f4ef83b5.jpg", "alt": "목재 서까래 천장과 미닫이창, 테이블이 있는 거실 공간", "category": "거실", "width": null, "height": null, "isPrimary": true, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", "unitId": null}, {"mediaId": "ad35769c-5299-4d39-b80d-5d88f1e1de14", "url": "/assets/mirror/6d0c447b6a0c32af.jpg", "alt": "소파와 탁자, 벽난로 조명이 있는 서까래 천장의 거실", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", "unitId": null}, {"mediaId": "83a2543f-4366-4427-b694-a7b1968a61cf", "url": "/assets/mirror/2d94158a48480fcd.jpg", "alt": "싱크대와 냉장고, 전자레인지가 구비된 실내 주방 공간", "category": "주방", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", "unitId": null}, {"mediaId": "990a89ed-9517-426c-8c17-fd9bf3c1a44d", "url": "/assets/mirror/d364f605223524bd.jpg", "alt": "주방 싱크대 앞에서 포즈를 취하고 있는 여성의 모습", "category": "주방", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", "unitId": null}, {"mediaId": "5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", "url": "/assets/mirror/e0ed8d1a27e09320.jpg", "alt": "벽면에 설치된 그림 화면과 벽난로 조명 장식", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", "unitId": null}, {"mediaId": "2fbcd37e-260f-4a4f-b00f-3e997e978c02", "url": "/assets/mirror/aa69a633f8e3a631.jpg", "alt": "창밖으로 조명을 비춘 정원 나무와 석등이 보이는 풍경", "category": "전망", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", "unitId": null}, {"mediaId": "d95857a0-44ad-422b-86de-00dee8aca08f", "url": "/assets/mirror/7095220ca156e25e.jpg", "alt": "목재 서까래 천장에 설치된 조형적인 디자인의 조명 기구", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", "unitId": null}, {"mediaId": "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", "url": "/assets/mirror/caa5e076e7d56ec2.jpg", "alt": "실내 벽면 홈 공간에 놓인 흰색 화병과 식물 장식", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", "unitId": null}, {"mediaId": "4981b572-10e2-4138-ba1d-a5d7defb4b4a", "url": "/assets/mirror/8e2ec99c433b6fe8.jpg", "alt": "실내 창문을 통해 바라본 푸른 하늘과 푸른 나무 풍경", "category": "전망", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", "unitId": null}], "faqs": [{"faqId": "8d48a94d-1a19-456f-b1a8-4144ddcb2186", "question": "체크인과 체크아웃 시간은 언제인가요?", "answer": "체크인은 15:00부터이며, 체크아웃은 11:00까지입니다.", "status": 3, "sourceType": 4, "sortOrder": 0}, {"faqId": "8f14678b-3589-42e9-8896-d214c0093784", "question": "객실 기준 인원과 최대 인원, 인원 추가 요금은 어떻게 되나요?", "answer": "A동과 B동 모두 기준 인원은 2명이며, 최대 4명까지 투숙하실 수 있습니다. 인원 추가 요금은 20,000원입니다.", "status": 3, "sourceType": 4, "sortOrder": 1}, {"faqId": "a13785c0-a627-44b5-b593-fac39712d6d4", "question": "객실 내 취사가 가능한가요?", "answer": "주방에서 간단한 조리는 가능합니다. 다만 생선구이·고기구이·튀김처럼 냄새가 잘 빠지지 않는 조리는 하실 수 없고, 객실 안에서 직화로 굽는 방식도 허용되지 않습니다. 그릴·숯·전기 전열기구 등 개인 취사도구는 반입이 금지되어 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 2}, {"faqId": "0eb535db-135f-484b-b0a4-1323744ace75", "question": "주차와 와이파이 이용이 가능한가요?", "answer": "네, 주차와 와이파이 모두 이용 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 3}, {"faqId": "cc268677-ad26-4685-a6a6-10a293658591", "question": "반려동물 동반이나 실내 흡연이 가능한가요?", "answer": "반려동물 동반은 불가하며, 전 구역 흡연이 불가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 4}, {"faqId": "4b8ac158-7648-4b2c-aa1a-9c6ca99b1223", "question": "A동과 B동은 무엇이 다른가요?", "answer": "A동은 편안한 일본집에 와 있는 듯한 느낌의 공간이고, B동은 아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 두 동 모두 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있으며, 기준 2인 · 최대 4인으로 같습니다. 침대 구성 등 객실 안 자세한 사항은 예약 채널이나 전화로 확인해 주세요.", "status": 3, "sourceType": 4, "sortOrder": 5}, {"faqId": "8bd75b48-49c3-475f-99c1-f5e64f49bedd", "question": "객실 요금은 얼마인가요?", "answer": "A동과 B동 모두 198,000원 ~ 350,000원입니다. 날짜와 시즌에 따라 달라지므로 정확한 금액은 예약 채널에서 확인해 주세요. 기준 인원(2인)을 넘으면 1인당 20,000원이 더해집니다.", "status": 3, "sourceType": 4, "sortOrder": 6}, {"faqId": "7b2bbfeb-761c-4a15-b410-43e18a69bb9e", "question": "예약 취소 및 환불 규정은 어떻게 되나요?", "answer": "입실 당일 취소와 노쇼(No Show)의 경우 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", "status": 3, "sourceType": 4, "sortOrder": 7}, {"faqId": "20e80f4d-322a-46ac-90b1-21fb6669b6b0", "question": "스테이,머뭄은 어떤 숙소인가요?", "answer": "스테이,머뭄은 전북 군산시 절골길 18(신흥동)에 있는 독채 숙소입니다. A동은 일본식 가정집 느낌, B동은 현대식 모던 스타일로 두 동이 있고, 편안한 침구와 좋은 위치를 내세운 곳입니다.", "status": 3, "sourceType": 4, "sortOrder": 8}, {"faqId": "724aeac2-46b1-4d6d-9748-892b5988a178", "question": "군산 독채 펜션을 찾는데, 여기도 독채인가요?", "answer": "네, 스테이,머뭄은 A동과 B동 모두 독채입니다. 동마다 독립된 정원과 창고형 카페 공간이 딸려 있고, 주방이 있어 취사도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 9}, {"faqId": "a50f8b18-1a2b-4025-ab0c-00d8ebcc9e8f", "question": "위치가 어디인가요?", "answer": "스테이,머뭄은 전북 군산시 절골길 18(신흥동 63-18)에 있습니다. 바로 옆 13m 거리에 군산 신흥동 일본식가옥(히로쓰 가옥)이 있는 원도심 골목 안입니다. 문의는 0507-1497-0983입니다.", "status": 3, "sourceType": 4, "sortOrder": 10}, {"faqId": "7bac199e-f095-4b44-acf6-c94d68181fc8", "question": "예약은 어디서 하나요?", "answer": "스테이,머뭄은 네이버 플레이스에서 숙소 정보를 확인하실 수 있고, 전화 0507-1497-0983으로도 문의하실 수 있습니다. 입실 당일 취소와 노쇼는 숙박요금의 100%가 부과되니 일정을 확인하고 예약해 주세요.", "status": 3, "sourceType": 4, "sortOrder": 11}, {"faqId": "2059ea18-e444-4b2f-97df-ac8ada6e72b0", "question": "커플 둘이 묵기 좋은 2인 독채인가요?", "answer": "네, 스테이,머뭄 A동과 B동 모두 기준 인원 2인의 독채입니다. 두 동을 각각 한 팀만 쓰고, 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 12}, {"faqId": "b3950b7b-935d-47a5-8119-67afb028a5ce", "question": "일본식 가옥 같은 숙소를 찾는데 여기가 맞나요?", "answer": "스테이,머뭄 A동이 일본식 가정집 느낌으로 꾸민 독채입니다. 디딤돌 마당과 독립된 정원, 창고형 카페 공간이 딸려 있습니다. B동은 같은 마당 안에 있는 현대식 모던 스타일 독채입니다.", "status": 3, "sourceType": 4, "sortOrder": 13}, {"faqId": "78fcc01a-099f-4e7d-a731-d93d18573f55", "question": "조용하고 프라이빗한 숙소인가요?", "answer": "스테이,머뭄은 A동·B동 두 동뿐인 독채 숙소라 다른 손님과 건물을 나눠 쓰지 않습니다. 두 동 모두 독립된 정원을 따로 두고 있고, 전 구역 금연입니다.", "status": 3, "sourceType": 4, "sortOrder": 14}, {"faqId": "feb316f6-f0b5-419c-83e1-45fdec718024", "question": "차 없이 걸어서 근대문화거리까지 갈 수 있나요?", "answer": "스테이,머뭄에서 히로쓰 가옥은 13m, 초원사진관 248m, 군산 영화의 거리 318m, 군산근대미술관 708m, 옛 군산세관 728m, 근대건축관(구 조선은행 군산지점) 788m 거리입니다. 원도심 근대 건축물이 숙소를 중심으로 1km 안에 모여 있습니다. 주차도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 15}, {"faqId": "681b921e-d15f-48af-8378-21e7754f500f", "question": "이성당이나 근처 맛집까지 얼마나 되나요?", "answer": "스테이,머뭄에서 이성당까지는 455m입니다. 그 밖에 요리주점 도란 135m, 베이커리 물밀소 297m, 백년가게 국밥집 일흥옥 300m, 명월갈비 351m, 꽃게장 한주옥 430m로 모두 가까이 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 16}, {"faqId": "92c2e8b2-4a62-4b11-8898-7d75fd6bc6f7", "question": "마당이나 정원이 따로 있나요?", "answer": "네. A동과 B동 각각 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다. A동에는 디딤돌 마당도 있습니다. 바다 전망은 아닙니다 — 마당과 거실에서 새소리를 들으며 쉬는 숙소입니다.", "status": 3, "sourceType": 4, "sortOrder": 17}, {"faqId": "2c5cfea9-e3c7-42e0-8353-87e5538f3f4e", "question": "선유도에 갈 건데 여기서 자도 될까요?", "answer": "네, 스테이,머뭄에서는 다리를 건너 고군산군도 선유도까지 차로 약 45분 걸립니다. 첫날은 군산 도심에서 저녁과 내항 밤 산책을, 이튿날 선유도와 장자도를 도는 1박 2일 코스를 추천 일정으로 안내하고 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 18}, {"faqId": "bab88a71-1cce-40cb-ad7a-9e8d1fdd5ff9", "question": "1박 2일이면 어떻게 돌면 좋을까요?", "answer": "스테이,머뭄이 안내하는 1박 2일 코스는 첫날 오후 체크인 후 빈해원에서 저녁, 군산 내항 밤 산책으로 마무리하고, 이튿날 선유도와 장자도를 도는 일정입니다. 걷는 코스를 원하시면 채만식문학관·내항 부잔교·째보선창·초원사진관·이성당으로 이어지는 반나절 탁류길 코스도 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 19}, {"faqId": "55288610-291a-4feb-95d8-29e3b4b605d7", "question": "군산이 처음인데 어디부터 보면 좋을까요?", "answer": "스테이,머뭄은 처음 오신 손님께 반나절 개항장 골목 코스를 안내합니다. 군산근대역사박물관에서 시작해 초원사진관, 이성당, 경암동 철길마을 순으로 도는 일정으로, 걷는 거리가 가장 짧은 코스입니다.", "status": 3, "sourceType": 4, "sortOrder": 20}, {"faqId": "a8fdcd07-70d4-4274-ac6f-1d0067beec2a", "question": "비 오는 날에는 뭘 하면 좋을까요?", "answer": "스테이,머뭄은 비 오는 날을 위한 실내 코스를 따로 안내합니다. 군산근대건축관에서 옛 은행 건물 전시를 보고, 한일옥에서 무국으로 몸을 데운 뒤, 째보선창 근처 카페에서 비가 그칠 때까지 앉아 있는 코스입니다. 숙소는 주방이 있어 취사도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 21}, {"faqId": "4a20242b-d1ee-4c35-95a9-12bd0d90445c", "question": "바비큐나 고기를 구워 먹을 수 있나요?", "answer": "바비큐는 하실 수 없습니다. 화재 예방을 위해 객실 안에서 생선이나 고기를 굽는 직화 방식은 허용되지 않으며, 그릴·숯·전기 전열기구 등 개인적으로 준비해 오시는 취사도구도 반입이 금지되어 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 22}, {"faqId": "4b1b6de0-40ca-44fa-af35-cbd69eeebd35", "question": "픽업 서비스가 있나요?", "answer": "픽업 서비스는 지원하지 않습니다.", "status": 3, "sourceType": 4, "sortOrder": 23}, {"faqId": "648cf02d-d4ed-46ee-8aef-b3b6426e857e", "question": "건물이 오래된 집이라던데 언제 지어졌나요?", "answer": "1920년대에 지어진 적산가옥입니다. 백 년 된 고택을 리모델링해 2024년 5월 독채 펜션으로 문을 열었습니다. 바로 옆이 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥)입니다.", "status": 3, "sourceType": 4, "sortOrder": 24}, {"faqId": "015a1e83-a916-43fa-a6c1-95d7ece1817d", "question": "침구는 깨끗한가요?", "answer": "침구류는 매일 세탁하고 살균·건조합니다. 인테리어와 가구, 소품도 하나하나 준비해 두었습니다.", "status": 3, "sourceType": 4, "sortOrder": 25}, {"faqId": "10fa8fd2-7a04-4c0c-935c-dd354f7d3112", "question": "미성년자끼리 이용할 수 있나요?", "answer": "숙박업소는 법적으로 청소년 혼숙이 금지되어 있습니다. 미성년자는 보호자를 동반하셔야 이용하실 수 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 26}, {"faqId": "48ec5a26-7e62-4e66-ae32-5917818bdce3", "question": "CCTV가 설치되어 있나요?", "answer": "고객님의 안전과 보안을 위해 대문 입구 쪽에 CCTV를 운영하고 있습니다. 객실 안에는 없습니다.", "status": 3, "sourceType": 4, "sortOrder": 27}, {"faqId": "9c3dadc6-bbc6-4f72-b4e8-c336dbfe11da", "question": "벌레가 나오지는 않나요?", "answer": "정기적으로 방역하고 있습니다. 다만 자연과 함께하는 공간이라 가끔 벌레나 이물질이 들어올 수 있으며, 이 사유로는 환불이 어려운 점 양해 부탁드립니다.", "status": 3, "sourceType": 4, "sortOrder": 28}, {"faqId": "1f83a416-f01d-4813-babe-011b039df462", "question": "예약 인원보다 사람이 늘면 어떻게 하나요?", "answer": "미리 숙소로 연락 주세요. 기준 인원(2인)을 넘으면 1인당 20,000원이 추가됩니다. 최대 인원(4인)을 넘으면 입실이 불가능할 수 있고, 그 사유로는 환불을 받으실 수 없습니다.", "status": 3, "sourceType": 4, "sortOrder": 29}, {"faqId": "8b0cfa98-9616-44ac-937d-dd5f0a8a4ca9", "question": "히로쓰 가옥 바로 옆이라던데 어떤 집인가요?", "answer": "스테이,머뭄은 1920년대에 지어진 집으로, 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥) 후문 바로 옆 13m 거리에 있습니다. 사장님은 이 집이 히로쓰 가옥 관리인이 살던 집이라고 전합니다. 백 년 된 고택을 리모델링해 2024년 5월 독채 펜션으로 문을 열었습니다.", "status": 3, "sourceType": 4, "sortOrder": 30}, {"faqId": "bbd98512-574e-4601-bd16-b48654aaf5d9", "question": "주요 관광지까지 걸어서 얼마나 걸리나요?", "answer": "초원사진관, 이성당, 군산 시간여행마을 등 주요 관광명소가 걸어서 최대 8분 거리입니다. 차 없이 도보 여행이 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 31}], "links": [{"channel": 3, "url": "https://m.place.naver.com/place/1133638931/home", "title": "스테이,머뭄 네이버 플레이스", "confirmed": true}, {"channel": 4, "url": "https://www.instagram.com/staymeomoom", "title": "스테이,머뭄 인스타그램", "confirmed": true}], "local": {"attractions": [{"name": "군산 신흥동 일본식가옥(히로쓰 가옥)", "category": "관광지", "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", "distanceText": "13m", "imageUrl": "/assets/mirror/f27b7440e2f1183f.jpg", "description": "군산 신흥동 일본식 가옥(구 히로쓰 가옥)은 일제강점기에 군산에서 포목점과 소규모 농장을 운영하며 군산부협의회 의원을 지낸 일본인이 건립한 일본식 2층 목조 가옥이다."}, {"name": "초원사진관", "category": "관광지", "searchQuery": "초원사진관", "distanceText": "248m", "imageUrl": "/assets/mirror/d5433fe48de3da05.jpg", "description": "초원사진관은 1998년 1월에 개봉한 영화 <8월의 크리스마스>의 촬영 장소로 유명한 곳이다. 이 영화는 배우 한석규와 심은하가 주연인 영화로 시한부 인생을 사는 사진사 정원(한석규)과 어느 날 그…"}, {"name": "군산 영화의 거리", "category": "관광지", "searchQuery": "군산 영화의 거리", "distanceText": "318m", "imageUrl": "/assets/mirror/6a0c445e6dd940af.jpg", "description": "시간과 공간을 초월한 특별한 경험을 할 수 있는 곳이다. 그 중에서 초원사진관은 영화 <8월의 크리스마스> 촬영지로 잘 알려진 관광명소이다."}, {"name": "우체통거리", "category": "관광지", "searchQuery": "우체통거리", "distanceText": "566m", "imageUrl": "/assets/mirror/43461e391f83b9e8.jpg", "description": "군산시 우체통거리는 2016년 도시재생사업으로 추진된 주민공모사업에 우체국 주변 주민들로 구성된 도란도란 공동체가 참여하면서 주민들이 직접 폐 우체통을 손질하고 그림을 그려 상가 앞에 설치하면서 조…"}, {"name": "군산근대미술관", "category": "관광지", "searchQuery": "군산근대미술관", "distanceText": "708m", "imageUrl": "/assets/mirror/d8764592a03b0103.jpg", "description": "18은행은 일본 나가사키에 본사를 두고 있던 은행으로, 숫자 18은 은행 설립인가 순서를 의미한다. 군산지점은 조선에서 7번째 지점으로 1907년에 설립되었다."}, {"name": "옛 군산세관", "category": "관광지", "searchQuery": "옛 군산세관", "distanceText": "728m", "imageUrl": "/assets/mirror/22af0bff45e5fce8.jpg", "description": "구 군산세관본관 건물이 완공된 것은 1908년으로, 일제강점기가 시작되기 전에 우리나라 정부가 만든 근대식 건물이다."}, {"name": "근대건축관 (구)조선은행군산지점", "category": "관광지", "searchQuery": "근대건축관 (구)조선은행군산지점", "distanceText": "788m", "imageUrl": "/assets/mirror/5000f0a4feb45788.jpg", "description": "1923년에 건립된 일제의 건물로 일제가 식민 지배를 위해 운영한 대표적인 금융시설이었다. 이 건물은 붉은 벽돌로 지은 4층 높이의 2층 건물로, 정면에 돌출된 현관을 중심으로 평아치를 5개 세우고…"}, {"name": "군산 시간여행마을", "category": "관광지", "searchQuery": "군산 시간여행마을", "distanceText": "1.1km", "imageUrl": "/assets/mirror/7ba545fdbc8ad273.jpg", "description": "군산은 근대사에서 수탈의 아픔과 이에 항거한 열정의 도시로, 일제강점기 근대 문화를 상상해볼 수 있는 문화재가 원도심을 중심으로 잘 보존되었다."}, {"name": "월명호수", "category": "관광지", "searchQuery": "월명호수", "distanceText": "1.6km", "imageUrl": "/assets/mirror/c8c6a295ae1c0810.jpg", "description": "월명공원의 품속에는 1912년에 시민에게 식수를 공급하기 위해 만든 제1수원지가 물안개를 뿜어내는 산 속의 호수로 변하여 산새와 작은 동물들이 목을 축이는 곳이 되었고 설림산 아래 천년고찰 은적사에…"}, {"name": "은적사(군산)", "category": "관광지", "searchQuery": "은적사(군산)", "distanceText": "2.3km", "imageUrl": "/assets/mirror/669abc2b08640d3e.jpg", "description": "은적사는 전라북도 내에 있는 사찰 가운데 가장 오래된 백제 시대 사찰이다. 천방사, 선림사 등으로도 불리었던 은적사는 대한불교 조계종 제17교구 본사인 금산사의 말사이다."}, {"name": "서천문화예술창작공간", "category": "관광지", "searchQuery": "서천문화예술창작공간", "distanceText": "2.6km", "imageUrl": "/assets/mirror/7f8f81d5eeb33f73.jpg", "description": "서천군 문화예술창작공간은 일제시대에 미곡창고로 사용된 건물로 이름에서 유추할 수 있듯이, 본래의 목적은 쌀을 보관하기 위해 세워진 건물이다."}, {"name": "경암동 철길마을", "category": "관광지", "searchQuery": "경암동 철길마을", "distanceText": "2.7km", "imageUrl": "/assets/mirror/6d59722cedddd566.jpg", "description": "경암동 철길마을은 1944년 전라북도 군산시 경암동에 준공하여 페이퍼 코리아 공장과 군산역을 연결하는 총연장 2.5km 철로 주변의 마을을 총괄하여 붙인 이름이다."}], "restaurants": [{"name": "도란", "category": "맛집", "searchQuery": "도란", "distanceText": "135m", "imageUrl": "/assets/mirror/12938240987c1f78.jpg", "description": "전라북도 군산시 월명동에 있는 요리주점이다. 와인과 함께 카페와 양식을 메뉴를 주로 다루고 있다. 대표 메뉴로는 감바스, 해산물 크림 스튜, 부채살 스테이크 등이 있다."}, {"name": "물밀소", "category": "맛집", "searchQuery": "물밀소", "distanceText": "297m", "imageUrl": "/assets/mirror/07600bb67b91dd15.jpg", "description": "월명동에 자리한 베이커리 물밀소는 신생 빵집으로 입소문이 자자하다. 붉은 벽돌의 아담한 외관을 가졌고 매장 내부에는 저온숙성한 반죽으로 빚어낸 건강한 빵들이 진열돼 있다."}, {"name": "일흥옥", "category": "맛집", "searchQuery": "일흥옥", "distanceText": "300m", "imageUrl": "/assets/mirror/3420ff7e357ab7ef.jpg", "description": "일흥옥은 군산 테디베어뮤지엄 인근에 위치한 국밥 전문점이다. 중소벤처기업부로부터 백년가게 인증을 받은 곳이다."}, {"name": "명월갈비", "category": "맛집", "searchQuery": "명월갈비", "distanceText": "351m", "imageUrl": "/assets/mirror/881d1f7ff820942f.jpg", "description": "군산 신창동에 있는 명월갈비는 군산 지역의 대표 양념 소갈비 전문 맛집이다. 플러스 등급의 한우 갈비를 비법 양념장으로 숙성한 단품 메뉴로 오랜 기간 동안 인기를 유지해오고 있다."}, {"name": "만남스넥", "category": "맛집", "searchQuery": "만남스넥", "distanceText": "405m", "imageUrl": "/assets/mirror/d2be5a053a944cfe.jpg", "description": "만남스넥은 군산 현지인들이 좋아하는 메뉴인 잡탕을 파는 분식집이다. 잡탕은 떡, 만두, 라면, 어묵 등을 넣고 같이 끓인 메뉴인데, 떡볶이나 라볶이보다 국물이 많고 부재료가 풍부해 잡탕으로 불리게…"}, {"name": "동국사다온", "category": "맛집", "searchQuery": "동국사다온", "distanceText": "418m", "imageUrl": "/assets/mirror/4db345bd2cb4f0d7.jpg", "description": "전라북도 군산시 일본식 사찰인 동국사 사찰 내에 있는 테이크아웃 커피 전문점이다. 불교 용품과 함께 대추차, 오미자차, 오룡차 등 다양한 차 종류를 판매하고 있다."}, {"name": "이성당", "category": "맛집", "searchQuery": "이성당", "distanceText": "455m", "imageUrl": "/assets/mirror/37c90494d28478e2.png", "description": "일본 시마네현 이즈모시에 살다가 1906년 조선으로 건너온 히로세 야스타로라는 일본인이 ‘이즈모야’라는 이름으로 문을 열어 영업한 것이 시초이다."}, {"name": "국제반점", "category": "맛집", "searchQuery": "국제반점", "distanceText": "467m", "imageUrl": "/assets/mirror/149c18951bdcaff8.jpg", "description": "군산 영화동에 있는 국제반점은 1960년대 초 진흥반점으로 개업해 현재는 상호를 국제 반점으로 변경하여 성업 중인 중식당이다. 영화 <타짜>에 등장해 군산의 관광 명소가 되었다."}], "festivals": [{"name": "2026 군산 은파벚꽃야시장", "month": "3~4월", "period": "2026년 3월 28일 – 4월 12일", "location": "은파호수공원", "description": "군산 은파벚꽃야시장은 상인분들의 한 해를 힘차게 시작할 수 있도록 돕는 자리이자 군산의 대표적인 봄 축제였던 벚꽃축제가 사라진 아쉬움을 해소하고자 기획하게 된 행사이다.", "searchQuery": "2026 군산 은파벚꽃야시장", "season": "봄", "startDate": "2026-03-28", "endDate": "2026-04-12", "imageUrl": "/assets/mirror/82606e203454e3ff.jpg"}, {"name": "군산 수제맥주&블루스 페스티벌", "month": "6월", "period": "2026년 6월 12일 – 14일", "location": "군산근대역사박물관 주차장", "description": "'군산 수제맥주&블루스 페스티벌'은 국내 유일의 로컬 수제맥주 축제로, 100% 군산보리로 만드는 군산맥아로 만든 진짜 우리 수제맥주와 블루스음악과 함께 항구도시 군산의 초여름 밤, 낭만을 만끽할…", "searchQuery": "군산 수제맥주&블루스 페스티벌", "season": "여름", "startDate": "2026-06-12", "endDate": "2026-06-14", "imageUrl": "/assets/mirror/92f65116f3e58ab6.jpg"}, {"name": "군산짬뽕페스티벌", "month": "10월", "period": "2025년 10월 9일 – 12일", "location": "군산 백년광장 일원(장미동)", "description": "70년 짬뽕역사의 도시, 군산에서 10월 9일부터 12일까지, 4일간 짬뽕페스티벌이 개최된다. 얼큰, 화끈, 개운한 국물 베이스인 해물가득한 짬뽕, 고기짬뽕 등 다양한 테마의 짬뽕을 축제장에서 선보…", "searchQuery": "군산짬뽕페스티벌", "season": "가을", "startDate": "2025-10-09", "endDate": "2025-10-12", "imageUrl": "/assets/mirror/a1b00af88aa06cd9.jpg"}, {"name": "군산시간여행축제", "month": "10월", "period": "2026년 10월 2일 – 5일", "location": "구·시청광장 및 시간여행마을 일원", "description": "군산시간여행축제는 일제강점기 민중의 항거와 치열한 삶의 역사를 간직한 근대 군산을 배경으로, 군산의 과거와 현재, 그리고 미래를 잇는 특별한 시간여행을 선사하는 축제이다.", "searchQuery": "군산시간여행축제", "season": "가을", "startDate": "2026-10-02", "endDate": "2026-10-05", "imageUrl": "/assets/mirror/4b4086345c20b968.jpg"}, {"name": "익산천만송이 국화축제", "month": "10월", "season": "가을", "location": "전북특별자치도 익산시 하나로 322 (어양동) · 차로 30분", "description": "가을을 대표하는 축제로, 대형 조형물에 국화를 심어 만든 작품과 전국 최고 규모의 국화정원, 국화분재 특별전시가 열린다.", "searchQuery": "익산천만송이 국화축제", "imageUrl": "/assets/mirror/6194651114d1e740.jpg"}, {"name": "익산마한문화대전", "month": "10월", "season": "가을", "location": "전북특별자치도 익산시 금마면 고도9길 41-14 · 차로 35분", "description": "익산 마한의 정통성을 다지고 익산을 마한 문화 선진지로 알리기 위해 여는 행사이다.", "searchQuery": "익산마한문화대전", "imageUrl": "/assets/mirror/49558512d8d52e1a.jpg"}, {"name": "강경젓갈축제", "month": "10월", "season": "가을", "location": "충청남도 논산시 강경읍 금백로 45 · 차로 40분", "description": "전통의 맛과 문화를 현대적으로 재해석한 축제로, 금강의 자연경관과 강경의 역사 자원을 함께 둘러볼 수 있다.", "searchQuery": "강경젓갈축제", "imageUrl": "/assets/mirror/7ccd68de6b280bc2.jpg"}, {"name": "부안 설(雪)숭어 축제", "month": "12월", "season": "겨울", "location": "전북특별자치도 부안군 부안읍 서외리 · 차로 35분", "description": "겨울철에 맛이 오르는 설숭어를 주제로 한 지역 특산 수산물 축제이다. 설숭어 음식 판매와 시식, 어촌 문화 체험 프로그램이 열린다.", "searchQuery": "부안 설숭어 축제", "imageUrl": "/assets/mirror/4389ded2dee1ad98.jpg"}, {"name": "서천 마량진항 해넘이 해돋이 행사", "month": "12월", "season": "겨울", "location": "충청남도 서천군 서면 서인로 58 · 차로 30분", "description": "12월 31일부터 1월 1일까지 마량진항 일원에서 열린다. 서해안에서 일몰과 일출을 한자리에서 볼 수 있는 곳이다.", "searchQuery": "서천 마량진항 해넘이 해돋이", "imageUrl": "/assets/mirror/8fc0a7522e3eb080.png"}, {"name": "선유도 여름 노을축제", "season": "여름", "month": "7월", "period": "2026년 7월 25일", "location": "군산 선유도해수욕장", "description": "서해 낙조 명소인 선유도해수욕장 특설무대에서 열리는 한여름 밤 축제입니다. 지역 예술인의 공연과 참여 행사가 열리고 폭죽쇼로 마무리합니다. 사진은 개최 장소인 선유도해수욕장입니다.", "searchQuery": "선유도 여름 노을축제", "imageUrl": "/assets/mirror/c971fb408ad897e7.jpg"}], "weather": {"temperature": 27.9, "condition": "구름많음", "observedAt": "2026-09-03T12:30", "notes": {"맑음": "마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.", "흐림": "빛이 낮게 깔리는 날입니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다.", "비": "창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다. 백 년 된 기와를 타고 내리는 소리가 다릅니다.", "눈": "기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다."}, "tempNotes": {"혹서": "낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.", "더움": "선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.", "선선": "걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.", "쌀쌀": "월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.", "추움": "밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다."}}, "syncedAt": "2026-09-09T13:40:00+00:00"}, "routes": [], "narrative": {"about": ["스테이,머뭄은 전북특별자치도 군산시 신흥동 절골길에 있는 독채 펜션입니다. 1920년대에 지어진 백 년 고택으로, 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥) 후문 바로 옆 — 걸어서 13m 거리입니다. 사장님은 이 집이 히로쓰 가옥 관리인이 살던 집이라고 전합니다. 2024년 5월에 리모델링을 마치고 문을 열었고, A동과 B동 두 동을 각각 한 팀만 씁니다. 기준 2인 · 최대 4인까지 묵을 수 있습니다.", "A동은 편안한 일본집에 와 있는 듯한 느낌의 공간이고, B동은 아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 두 동 모두 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있어, 마당과 거실에서 새소리를 들으며 쉴 수 있습니다.", "인테리어와 가구, 소품, 침구를 하나하나 준비해 두었습니다. 특히 침구류는 매일 세탁·살균하고 건조해 쓰시는 분이 바뀔 때마다 새것처럼 나갑니다."], "summary": "1920년대 적산가옥을 고쳐 지은 독채 두 동, 매일 세탁·살균하는 침구", "tagline": "히로쓰 가옥 담 너머, 백 년 된 집 한 채", "catchphrases": {"version": 1, "items": [{"text": "백 년 된 집에서 하룻밤", "kind": "general"}, {"text": "담 너머는 히로쓰 가옥입니다", "kind": "general"}, {"text": "독채 두 동, 마당은 각자 씁니다", "kind": "general"}, {"text": "기와 아래에서 아침을 맞습니다", "kind": "general"}, {"text": "새소리로 눈이 떠지는 집", "kind": "general"}, {"text": "골목이 조용해지는 시간에 도착하세요", "kind": "general"}, {"text": "침구는 매일 새것처럼 나갑니다", "kind": "general"}, {"text": "마당 평상에 앉으면 항구 소리가 들립니다", "kind": "general"}, {"text": "1920년대에 지은 집, 지금 쓰는 집", "kind": "general"}, {"text": "대문을 닫으면 골목이 멀어집니다", "kind": "general"}, {"text": "창고를 고친 카페 공간이 딸려 있습니다", "kind": "general"}, {"text": "밤에는 정원 석등만 켜 둡니다", "kind": "general"}, {"text": "초원사진관까지 걸어서 3분", "kind": "general"}, {"text": "원도심 한가운데, 차는 세워 두세요", "kind": "general"}, {"text": "A동은 일본식, B동은 모던입니다", "kind": "general"}, {"text": "네 사람까지 묶는 독채입니다", "kind": "general"}, {"text": "오후 세 시부터 이 집은 손님 것입니다", "kind": "general"}, {"text": "이 골목에서는 서두를 일이 없습니다", "kind": "general"}, {"text": "백 년 된 기와가 비를 받습니다", "kind": "general"}, {"text": "여행이라기보다 하루의 이사입니다", "kind": "general"}, {"text": "군산에서 가장 조용한 담 안쪽", "kind": "general"}, {"text": "이 집의 시간은 조금 느립니다", "kind": "general"}, {"text": "마당, 정원, 그리고 평상 하나", "kind": "general"}, {"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"}, {"text": "창을 열면 옆집 기와가 보입니다", "kind": "general"}, {"text": "담배 냄새가 없는 집입니다", "kind": "general"}, {"text": "이불에서 볕 냄새가 납니다", "kind": "general"}, {"text": "두 동 모두 남의 인기척이 없습니다", "kind": "general"}, {"text": "내항까지 걸어 십 분", "kind": "general"}, {"text": "오래된 것을 고쳐 쓰는 집", "kind": "general"}, {"text": "부엌이 있어 아침은 지어 드셔도 됩니다", "kind": "general"}, {"text": "밤에는 골목 등만 남습니다", "kind": "general"}, {"text": "백 년 된 집이 아직 손님을 받습니다", "kind": "general"}, {"text": "개항장 골목의 끝집입니다", "kind": "general"}, {"text": "문패보다 기와를 보고 찾아오세요", "kind": "general"}, {"text": "걸어서 도는 여행에 알맞은 자리", "kind": "general"}, {"text": "마루에 앉는 시간이 일정의 절반입니다", "kind": "general"}, {"text": "좁은 골목 끝, 대문 하나", "kind": "general"}, {"text": "여기서는 계획을 반쯤 접어도 됩니다", "kind": "general"}, {"text": "하루를 통째로 쓰는 숙소", "kind": "general"}, {"text": "정원에 꽃이 먼저 옵니다", "kind": "season", "season": "봄"}, {"text": "봄에는 대문을 열어 둡니다", "kind": "season", "season": "봄"}, {"text": "벚꽃 지는 골목을 걸어 오세요", "kind": "season", "season": "봄"}, {"text": "마당에 앉는 계절이 시작됩니다", "kind": "season", "season": "봄"}, {"text": "새소리가 가장 많은 철입니다", "kind": "season", "season": "봄"}, {"text": "창을 열면 봄이 방까지 들어옵니다", "kind": "season", "season": "봄"}, {"text": "처마 그늘이 가장 긴 계절입니다", "kind": "season", "season": "여름"}, {"text": "마당에 물을 뿌리면 시원해집니다", "kind": "season", "season": "여름"}, {"text": "여름 밤에는 평상이 제일 좋습니다", "kind": "season", "season": "여름"}, {"text": "창고형 카페가 낮에는 서늘합니다", "kind": "season", "season": "여름"}, {"text": "소나기는 기와가 받아 줍니다", "kind": "season", "season": "여름"}, {"text": "해가 길어 저녁이 넉넉합니다", "kind": "season", "season": "여름"}, {"text": "기와 위로 가을 볕이 마릅니다", "kind": "season", "season": "가을"}, {"text": "가을 바람이 담을 넘어옵니다", "kind": "season", "season": "가을"}, {"text": "가을 골목은 해가 빨리 눕습니다", "kind": "season", "season": "가을"}, {"text": "창을 열어 두고 자도 되는 밤입니다", "kind": "season", "season": "가을"}, {"text": "가을에는 평상이 가장 좋은 자리입니다", "kind": "season", "season": "가을"}, {"text": "마당에 잎이 떨어지는 계절입니다", "kind": "season", "season": "가을"}, {"text": "기와에 눈이 앉으면 골목이 조용해집니다", "kind": "season", "season": "겨울"}, {"text": "겨울 아침 마당은 서리 밭입니다", "kind": "season", "season": "겨울"}, {"text": "이불 속에서 겨울 바람 소리를 듣습니다", "kind": "season", "season": "겨울"}, {"text": "눈 오면 정원 석등만 켜 둡니다", "kind": "season", "season": "겨울"}, {"text": "항구 바람이 매운 계절입니다", "kind": "season", "season": "겨울"}, {"text": "겨울에는 카페 공간이 가장 아늑합니다", "kind": "season", "season": "겨울"}, {"text": "새해 첫 아침을 백 년 집에서", "kind": "month", "month": 1}, {"text": "겨울 골목이 가장 조용한 달입니다", "kind": "month", "month": 1}, {"text": "아직 바람이 찬 원도심입니다", "kind": "month", "month": 2}, {"text": "겨울 끝을 마당에서 봅니다", "kind": "month", "month": 2}, {"text": "정원에 첫 꽃이 오는 달입니다", "kind": "month", "month": 3}, {"text": "대문을 열어 두기 시작합니다", "kind": "month", "month": 3}, {"text": "벚꽃 지는 골목을 걷는 달입니다", "kind": "month", "month": 4}, {"text": "마당에 앉는 시간이 길어집니다", "kind": "month", "month": 4}, {"text": "새소리가 가장 많은 달입니다", "kind": "month", "month": 5}, {"text": "창을 열어 두고 자는 밤입니다", "kind": "month", "month": 5}, {"text": "처마 그늘이 길어지는 달입니다", "kind": "month", "month": 6}, {"text": "여름 앞의 원도심은 한가합니다", "kind": "month", "month": 6}, {"text": "소나기를 기와가 받는 달입니다", "kind": "month", "month": 7}, {"text": "여름 밤 평상이 제일 좋습니다", "kind": "month", "month": 7}, {"text": "낮에는 카페 공간이 서늘합니다", "kind": "month", "month": 8}, {"text": "해가 길어 저녁이 넉넉한 달입니다", "kind": "month", "month": 8}, {"text": "볕이 마르기 시작하는 달입니다", "kind": "month", "month": 9}, {"text": "가을이 담을 넘어오는 구월입니다", "kind": "month", "month": 9}, {"text": "잎이 마당에 떨어지는 달입니다", "kind": "month", "month": 10}, {"text": "걷기에 가장 좋은 달입니다", "kind": "month", "month": 10}, {"text": "해가 빨리 눕는 골목입니다", "kind": "month", "month": 11}, {"text": "이불이 가장 따뜻한 달입니다", "kind": "month", "month": 11}, {"text": "기와에 눈이 앉는 달입니다", "kind": "month", "month": 12}, {"text": "한 해의 끝을 조용히 보내는 자리", "kind": "month", "month": 12}, {"text": "마당 평상에 앉으면 새소리만 들립니다", "kind": "weather", "weather": "맑음"}, {"text": "정원 쪽으로 그늘이 길게 눕는 시간입니다", "kind": "weather", "weather": "맑음"}, {"text": "기와가 마르는 냄새가 납니다", "kind": "weather", "weather": "맑음"}, {"text": "빛이 부드러운 날입니다", "kind": "weather", "weather": "구름많음"}, {"text": "골목 사진이 제일 잘 나오는 빛입니다", "kind": "weather", "weather": "구름많음"}, {"text": "빛이 낮게 깔리는 날입니다", "kind": "weather", "weather": "흐림"}, {"text": "이런 날은 카페 공간이 가장 아늑합니다", "kind": "weather", "weather": "흐림"}, {"text": "백 년 된 기와를 타고 내리는 소리가 다릅니다", "kind": "weather", "weather": "비"}, {"text": "카페 공간에서 빗소리를 들으실 수 있습니다", "kind": "weather", "weather": "비"}, {"text": "비 오는 골목에는 사람이 없습니다", "kind": "weather", "weather": "비"}, {"text": "기와에 눈이 앉으면 골목이 통째로 조용해집니다", "kind": "weather", "weather": "눈"}, {"text": "눈 밟는 소리가 담 안에서 크게 들립니다", "kind": "weather", "weather": "눈"}]}}, "theme": {"templateId": "stay-retro", "colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0", "card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"}, "fontStyle": "옛 간판체", "sections": [{"id": "hero", "name": "히어로", "enabled": true, "locked": true}, {"id": "intro", "name": "소개", "enabled": true, "locked": false}, {"id": "rooms", "name": "객실 안내", "enabled": true, "locked": false}, {"id": "event", "name": "스테이,머뭄 소식", "enabled": true, "locked": false, "data": "{\"kind\":\"event\",\"version\":1,\"title\":\"스테이,머뭄 소식\",\"subtitle\":\"미리 알아 두시면 좋은 것들\",\"linkUrl\":\"https://www.instagram.com/staymeomoom\",\"linkLabel\":\"인스타그램에서 보기\",\"items\":[{\"kind\":\"공지\",\"title\":\"입실 전 확인해 주세요\",\"summary\":\"머무시기 전에 꼭 읽어 주셔야 하는 것들입니다. 미숙지로 생긴 불편과 손해는 도와드리기 어렵습니다.\",\"body\":\"· 반려동물은 입실하실 수 없습니다.\\n· 건물과 객실 안은 절대 금연입니다. 바로 옆이 군산 신흥동 일본식가옥(히로쓰 가옥)이라 화재에 각별히 유의해 주세요.\\n· 냄새가 잘 빠지지 않는 생선구이·고기구이·튀김류 조리는 하실 수 없습니다.\\n· 객실 안에서 음식을 드실 때 오염에 주의해 주세요.\\n· 객실 시설과 비품, 침구가 손상·파손·오염되면 변상을 요청드릴 수 있습니다.\\n· 정기적으로 방역하고 있으나 자연과 함께하는 공간이라 가끔 벌레나 이물질이 들어올 수 있습니다. 이 사유로는 환불이 어려운 점 양해 부탁드립니다.\\n· 귀중품과 현금은 분실 위험이 있으니 직접 관리해 주세요.\\n· 안전과 보안을 위해 대문 입구 쪽에 CCTV를 운영하고 있습니다. 객실 안에는 없습니다.\\n· 숙박업소는 법적으로 청소년 혼숙이 금지되어 있습니다. 미성년자는 보호자를 동반하셔야 이용하실 수 있습니다.\",\"verified\":\"확인\"},{\"kind\":\"공지\",\"title\":\"인원이 늘면 미리 연락 주세요\",\"summary\":\"예약하신 인원보다 늘어나는 경우, 오시기 전에 숙소로 연락 부탁드립니다.\",\"body\":\"· 기준 인원(2인)을 넘으면 1인당 20,000원이 추가됩니다.\\n· 최대 인원(4인)을 넘으면 입실이 불가능할 수 있고, 이 사유로는 환불을 받으실 수 없습니다.\\n· 다음 이용 고객을 위해 입실 15:00 이후, 퇴실 11:00 이전을 지켜 주세요.\",\"howTo\":\"연락처 0507-1497-0983\",\"verified\":\"확인\"},{\"kind\":\"공지\",\"title\":\"직화·개인 취사도구는 반입이 안 됩니다\",\"summary\":\"백 년 된 목조 고택이라 화재 예방 규정이 엄격합니다.\",\"body\":\"· 객실 안에서 생선이나 고기 등을 굽는 직화 방식은 허용되지 않습니다.\\n· 그릴, 숯, 전기·전열기구 등 개인적으로 준비해 오시는 취사도구는 반입이 금지되어 있습니다.\\n· 주방에서 간단한 조리는 하실 수 있습니다.\\n· 흡연은 지정된 장소를 이용해 주시고, 다른 이용객에게 피해가 갈 수 있는 오락·음주·고성방가는 삼가 주세요.\",\"verified\":\"확인\"}]}"}, {"id": "info", "name": "기본 정보", "enabled": true, "locked": true}, {"id": "booking", "name": "실시간 예약", "enabled": false, "locked": false}, {"id": "video", "name": "ADO2 영상 보기", "enabled": true, "locked": false, "data": "{\"kind\": \"video\", \"version\": 1, \"title\": \"ADO2 영상 보기\", \"subtitle\": \"ADO2로 만든 홍보 영상입니다.\", \"items\": [{\"url\": \"https://youtube.com/shorts/c2ZdwhaB7S4\", \"caption\": \"머뭄의 하루\", \"verified\": \"확인\"}, {\"url\": \"https://youtube.com/shorts/_bbo3dVgOXs\", \"caption\": \"마당과 창고형 카페 공간\", \"verified\": \"확인\"}, {\"url\": \"https://youtube.com/shorts/qrCG4rOeJvs\", \"caption\": \"밤의 머뭄\", \"verified\": \"확인\"}], \"linkUrl\": \"https://ado2.o2osolution.ai/\", \"linkLabel\": \"ADO2 알아보기\"}"}, {"id": "photos", "name": "사진 갤러리", "enabled": true, "locked": false, "variantId": "photos.carousel"}, {"id": "map", "name": "오시는 길", "enabled": true, "locked": true}, {"id": "festival", "name": "계절별 축제", "enabled": true, "locked": false}, {"id": "local", "name": "지역 정보", "enabled": true, "locked": false}, {"id": "itinerary", "name": "추천 일정", "enabled": true, "locked": false, "data": "{\"kind\": \"itinerary\", \"version\": 1, \"title\": \"추천 일정\", \"subtitle\": \"머뭄에서 나서고 머뭄으로 돌아옵니다\", \"items\": [{\"name\": \"섬까지 다녀오는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"차를 가져온 손님\", \"why\": \"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 45, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 8, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 75, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 150, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"늦은 점심 · 국제반점\", \"minutes\": 55, \"moveMinutes\": 85, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"걸어서만 도는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"차 없이 오는 손님\", \"why\": \"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 49, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 52, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 42, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 79, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 47, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 52, \"moveMinutes\": 4, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 26, \"moveMinutes\": 4, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"비가 와도 되는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"비 예보를 보고 오는 손님\", \"why\": \"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 39, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 88, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 44, \"moveMinutes\": 3, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 68, \"moveMinutes\": 5, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 39, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 29, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 80, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 52, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 57, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 째보선창 근처 카페\", \"minutes\": 69, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다.\", \"searchQuery\": \"군산 째보선창 카페\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 19, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"근대건축만 보는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"건물을 보러 오는 손님\", \"why\": \"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 28, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 61, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 71, \"moveMinutes\": 6, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 73, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대미술관\", \"minutes\": 70, \"moveMinutes\": 9, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 48, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 19, \"moveMinutes\": 12, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"이성당으로 시작하는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"빵을 좋아하는 손님\", \"why\": \"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 27, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 22, \"moveMinutes\": 6, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 72, \"moveMinutes\": 6, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 36, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 50, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 36, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 81, \"moveMinutes\": 12, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"철길마을까지 가는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"사진을 찍는 손님\", \"why\": \"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:40\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 28, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 33, \"moveMinutes\": 8, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 52, \"moveMinutes\": 10, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 9, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 77, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 80, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 48, \"moveMinutes\": 11, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 57, \"moveMinutes\": 6, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"항구만 도는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"바다가 보고 싶은 손님\", \"why\": \"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:40\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 37, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 21, \"moveMinutes\": 17, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 74, \"moveMinutes\": 13, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 42, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 79, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 째보선창\", \"minutes\": 32, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 58, \"moveMinutes\": 12, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 32, \"moveMinutes\": 7, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 13, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"소설 『탁류』를 따라 걷는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"채만식을 읽어 본 손님\", \"why\": \"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 33, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"저녁 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 11, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:00\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 82, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 채만식문학관\", \"minutes\": 66, \"moveMinutes\": 18, \"latitude\": 36.0085981, \"longitude\": 126.7578853, \"note\": \"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다.\", \"searchQuery\": \"군산 채만식문학관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 17, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 33, \"moveMinutes\": 12, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 13, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"아이와 함께 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"아이를 데려온 손님\", \"why\": \"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 26, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 21, \"moveMinutes\": 6, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 만남스넥\", \"minutes\": 42, \"moveMinutes\": 4, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 34, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 26, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 42, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 이성당\", \"minutes\": 30, \"moveMinutes\": 11, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"오후 · 월명호수\", \"minutes\": 42, \"moveMinutes\": 10, \"latitude\": 35.9766391, \"longitude\": 126.6913696, \"note\": \"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다.\", \"searchQuery\": \"군산 월명호수\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 9, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"늦게 도착하는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"퇴근하고 오는 손님\", \"why\": \"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"18:20\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 75, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 75, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 50, \"moveMinutes\": 6, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 20, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"천천히 도는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"쉬러 오는 손님\", \"why\": \"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:20\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 35, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 40, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 92, \"moveMinutes\": 7, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 46, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 35, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 223, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 53, \"moveMinutes\": 5, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 173, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 74, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 70, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 44, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 50, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 75, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"섬과 도심을 반씩 도는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차를 가져온 손님\", \"why\": \"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 30, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 3, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"07:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 60, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 150, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"오후 · 장자도\", \"minutes\": 50, \"moveMinutes\": 20, \"latitude\": 35.8101, \"longitude\": 126.3973, \"note\": \"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다.\", \"searchQuery\": \"군산 장자도\"}, {\"name\": \"늦은 점심 · 국제반점\", \"minutes\": 55, \"moveMinutes\": 88, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 168, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 75, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 77, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 35, \"moveMinutes\": 4, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"골목을 세 번 나눠 걷는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차 없이 오는 손님\", \"why\": \"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 49, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 66, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 99, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 6, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 22, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 164, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 82, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 우체통거리\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 44, \"moveMinutes\": 4, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 66, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"먹으러 오는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"맛집을 도는 손님\", \"why\": \"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:30\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 물밀소\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9845243, \"longitude\": 126.7073211, \"note\": \"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다.\", \"searchQuery\": \"군산 물밀소\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 34, \"moveMinutes\": 6, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 86, \"moveMinutes\": 3, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 46, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 69, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산 시간여행마을\", \"minutes\": 69, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 57, \"moveMinutes\": 6, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 29, \"moveMinutes\": 3, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 127, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 80, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 104, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 동국사\", \"minutes\": 50, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 63, \"moveMinutes\": 10, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 103, \"moveMinutes\": 5, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"아무 데도 안 가는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"쉬려고만 오는 손님\", \"why\": \"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 96, \"moveMinutes\": 3, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 48, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 232, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 44, \"moveMinutes\": 6, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 187, \"moveMinutes\": 6, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 5, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:00\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 98, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 동국사\", \"minutes\": 46, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"점심 · 물밀소\", \"minutes\": 46, \"moveMinutes\": 3, \"latitude\": 35.9845243, \"longitude\": 126.7073211, \"note\": \"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다.\", \"searchQuery\": \"군산 물밀소\"}, {\"name\": \"오후 · 월명호수\", \"minutes\": 65, \"moveMinutes\": 9, \"latitude\": 35.9766391, \"longitude\": 126.6913696, \"note\": \"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다.\", \"searchQuery\": \"군산 월명호수\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 52, \"moveMinutes\": 9, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"사진만 찍는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"사진을 찍는 손님\", \"why\": \"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:50\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 24, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 28, \"moveMinutes\": 8, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 64, \"moveMinutes\": 7, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 32, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 24, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 48, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 40, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 43, \"moveMinutes\": 11, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 45, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 21, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 190, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 45, \"moveMinutes\": 7, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 24, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 우체통거리\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 36, \"moveMinutes\": 8, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 9, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"비가 와도 되는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"비 예보를 보고 오는 손님\", \"why\": \"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 5, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 72, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 54, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 60, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 째보선창 근처 카페\", \"minutes\": 72, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다.\", \"searchQuery\": \"군산 째보선창 카페\"}, {\"name\": \"오후 · 동국사다온\", \"minutes\": 48, \"moveMinutes\": 8, \"latitude\": 35.9860907, \"longitude\": 126.71444, \"note\": \"대추차나 오미자차를 시켜 절 마당을 보며 마십니다.\", \"searchQuery\": \"군산 동국사 다온\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 84, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 90, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 80, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 채만식문학관\", \"minutes\": 72, \"moveMinutes\": 18, \"latitude\": 36.0085981, \"longitude\": 126.7578853, \"note\": \"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다.\", \"searchQuery\": \"군산 채만식문학관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 60, \"moveMinutes\": 17, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 42, \"moveMinutes\": 3, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 48, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"늦게 시작하는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"아침이 힘든 손님\", \"why\": \"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 30, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 25, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 80, \"moveMinutes\": 6, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 60, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 50, \"moveMinutes\": 5, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 10, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 20, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 156, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 35, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 30, \"moveMinutes\": 6, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"섬에서 하루를 다 쓰는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차를 가져온 손님\", \"why\": \"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 36, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 17, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 66, \"moveMinutes\": 13, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"07:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 57, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 142, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"오후 · 장자도\", \"minutes\": 48, \"moveMinutes\": 20, \"latitude\": 35.8101, \"longitude\": 126.3973, \"note\": \"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다.\", \"searchQuery\": \"군산 장자도\"}, {\"name\": \"늦은 점심 · 한일옥\", \"minutes\": 48, \"moveMinutes\": 88, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 170, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 71, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 71, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 43, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 38, \"moveMinutes\": 9, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 경암동 철길마을\", \"minutes\": 48, \"moveMinutes\": 11, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 12, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"소설 『탁류』를 따라 걷는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"채만식을 읽어 본 손님\", \"why\": \"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 33, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 13, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 231, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 5, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 173, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 77, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 82, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 99, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 61, \"moveMinutes\": 5, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 33, \"moveMinutes\": 3, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 4, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"철길마을과 항구를 같이 보는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"사진을 찍는 손님\", \"why\": \"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 경암동 철길마을\", \"minutes\": 45, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 27, \"moveMinutes\": 11, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 50, \"moveMinutes\": 3, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 36, \"moveMinutes\": 9, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 54, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산 내항 부잔교\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 45, \"moveMinutes\": 11, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 45, \"moveMinutes\": 13, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 21, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 190, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 68, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 68, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 구 군산세관 본관\", \"minutes\": 27, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 45, \"moveMinutes\": 12, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 9, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}]}"}, {"id": "story", "name": "군산 이야기", "enabled": true, "locked": false}, {"id": "songs", "name": "가요 다방", "enabled": false, "locked": false, "data": "{\"kind\": \"songs\", \"version\": 1, \"title\": \"가요 다방\", \"subtitle\": \"군산을 노래한 25곡 — 눌러서 들어 보세요\", \"items\": [{\"title\": \"잘 있거라 군산항\", \"artist\": \"안다성\", \"labelColor\": \"#2f6b4f\", \"story\": \"월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9E%98%20%EC%9E%88%EA%B1%B0%EB%9D%BC%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%95%88%EB%8B%A4%EC%84%B1\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"손석우\", \"composer\": \"손석우\", \"year\": 1963}, {\"title\": \"헤어진 군산항\", \"artist\": \"박제연\", \"labelColor\": \"#2a6053\", \"story\": \"비 내리는 밤 부두에서 헤어지는 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%97%A4%EC%96%B4%EC%A7%84%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B0%95%EC%A0%9C%EC%97%B0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송운선\", \"composer\": \"송운선\", \"year\": 1965}, {\"title\": \"님 떠난 군산항\", \"artist\": \"이미자\", \"labelColor\": \"#1d3c74\", \"story\": \"떠나는 배를 보내는 항구의 정서를 담았습니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%8B%98%20%EB%96%A0%EB%82%9C%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"조형식\", \"composer\": \"백영호\", \"year\": 1966}, {\"title\": \"금강 나그네\", \"artist\": \"이미자\", \"labelColor\": \"#d4551f\", \"story\": \"금강 하구와 군산항 부두를 배경으로 한 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%20%EB%82%98%EA%B7%B8%EB%84%A4%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이병훈\", \"composer\": \"박춘석\", \"year\": 1966}, {\"title\": \"잘 있거라 군산항구\", \"artist\": \"최정자\", \"labelColor\": \"#8a4b2a\", \"story\": \"떠나는 사람의 말로 부르는 이별가입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9E%98%20%EC%9E%88%EA%B1%B0%EB%9D%BC%20%EA%B5%B0%EC%82%B0%ED%95%AD%EA%B5%AC%20%EC%B5%9C%EC%A0%95%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최치수\", \"composer\": \"유금춘\", \"year\": 1967}, {\"title\": \"군산 마도로스\", \"artist\": \"이상열\", \"labelColor\": \"#334b6d\", \"story\": \"항구를 드나들던 뱃사람의 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EB%A7%88%EB%8F%84%EB%A1%9C%EC%8A%A4%20%EC%9D%B4%EC%83%81%EC%97%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"year\": 1967}, {\"title\": \"군산 부르스\", \"artist\": \"유주용\", \"labelColor\": \"#4a3f6b\", \"story\": \"1960년대 말 군산 거리의 분위기를 담은 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EB%B6%80%EB%A5%B4%EC%8A%A4%20%EC%9C%A0%EC%A3%BC%EC%9A%A9\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"반야월\", \"composer\": \"강도미\", \"year\": 1969}, {\"title\": \"이별의 군산항 부두\", \"artist\": \"최정자\", \"labelColor\": \"#6b2f3a\", \"story\": \"부두에서의 이별을 다시 한 번 부른 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9D%B4%EB%B3%84%EC%9D%98%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B6%80%EB%91%90%20%EC%B5%9C%EC%A0%95%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최치수\", \"composer\": \"유금춘\", \"year\": 1969}, {\"title\": \"군산항 부르스\", \"artist\": \"최하우\", \"labelColor\": \"#2f5b6b\", \"story\": \"1980년대에 나온 군산항 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B6%80%EB%A5%B4%EC%8A%A4%20%EC%B5%9C%ED%95%98%EC%9A%B0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최하우\", \"composer\": \"김성근\", \"year\": 1984}, {\"title\": \"나의 사랑 군산이여\", \"artist\": \"오단비\", \"labelColor\": \"#a34a2a\", \"story\": \"군산을 향한 애정을 정면으로 부르는 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%82%98%EC%9D%98%20%EC%82%AC%EB%9E%91%20%EA%B5%B0%EC%82%B0%EC%9D%B4%EC%97%AC%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"정종택\", \"year\": 1989}, {\"title\": \"군산항 엘레지\", \"artist\": \"오단비\", \"labelColor\": \"#7a3b52\", \"story\": \"항구 도시의 비가입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%97%98%EB%A0%88%EC%A7%80%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"정종택\"}, {\"title\": \"선유도 아가씨\", \"artist\": \"오단비\", \"labelColor\": \"#2b6b7a\", \"story\": \"고군산군도 선유도를 배경으로 한 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%84%A0%EC%9C%A0%EB%8F%84%20%EC%95%84%EA%B0%80%EC%94%A8%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"군산찬가\", \"artist\": \"오단비\", \"labelColor\": \"#c8873a\", \"story\": \"은파와 새만금이 가사에 나오는 군산 찬가입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%B0%AC%EA%B0%80%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"오성산\", \"artist\": \"오단비\", \"labelColor\": \"#4f6b3a\", \"story\": \"금강 하구를 내려다보는 오성산을 노래했습니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%98%A4%EC%84%B1%EC%82%B0%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"함께하는 군산\", \"artist\": \"오은주\", \"labelColor\": \"#b5603a\", \"story\": \"군산시가 제작한 음반의 첫 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94%20%EA%B5%B0%EC%82%B0%20%EC%98%A4%EC%9D%80%EC%A3%BC\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"김동찬\", \"composer\": \"김동찬\"}, {\"title\": \"새만금 연가\", \"artist\": \"채미영\", \"labelColor\": \"#356b8a\", \"story\": \"새만금을 소재로 한 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%83%88%EB%A7%8C%EA%B8%88%20%EC%97%B0%EA%B0%80%20%EC%B1%84%EB%AF%B8%EC%98%81\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"김동찬\", \"composer\": \"김동찬 · 김수환\"}, {\"title\": \"군산시민의 노래\", \"artist\": \"오단비\", \"labelColor\": \"#5a5a4a\", \"story\": \"금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%8B%9C%EB%AF%BC%EC%9D%98%20%EB%85%B8%EB%9E%98%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"군산문화원\", \"composer\": \"군산예총\"}, {\"title\": \"군산 아줌마\", \"artist\": \"박솔미\", \"labelColor\": \"#a3562a\", \"story\": \"선창가 거리와 새만금이 함께 나오는 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EC%95%84%EC%A4%8C%EB%A7%88%20%EB%B0%95%EC%86%94%EB%AF%B8\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송두호\", \"composer\": \"송두호\"}, {\"title\": \"내 고향 군산항\", \"artist\": \"정애란\", \"labelColor\": \"#6b4a2a\", \"story\": \"고향으로서의 군산항을 부른 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%82%B4%20%EA%B3%A0%ED%96%A5%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%A0%95%EC%95%A0%EB%9E%80\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"정애란\", \"composer\": \"정애란\"}, {\"title\": \"군산 가는 길\", \"artist\": \"황상준\", \"labelColor\": \"#3a5a4a\", \"story\": \"군산으로 향하는 길을 제목으로 삼은 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EA%B0%80%EB%8A%94%20%EA%B8%B8%20%ED%99%A9%EC%83%81%EC%A4%80\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"군산 추억\", \"artist\": \"임인건\", \"labelColor\": \"#4a4a6b\", \"story\": \"재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EC%B6%94%EC%96%B5%20%EC%9E%84%EC%9D%B8%EA%B1%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"군산에서\", \"artist\": \"임인건\", \"labelColor\": \"#3f5f6b\", \"story\": \"같은 연주자가 군산에서 받은 인상을 담았습니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%97%90%EC%84%9C%20%EC%9E%84%EC%9D%B8%EA%B1%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"헤어진 군산항\", \"artist\": \"김창환\", \"labelColor\": \"#2a6053\", \"story\": \"군산시 제작 음반에 다시 실린 판입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%97%A4%EC%96%B4%EC%A7%84%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EA%B9%80%EC%B0%BD%ED%99%98\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송운선\", \"composer\": \"송운선\"}, {\"title\": \"금강 아가씨\", \"artist\": \"이미자\", \"labelColor\": \"#d4551f\", \"story\": \"금강을 배경으로 한 이미자의 또 다른 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%20%EC%95%84%EA%B0%80%EC%94%A8%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"금강의 비가\", \"artist\": \"전승애\", \"labelColor\": \"#7a4a6b\", \"story\": \"금강을 제목에 올린 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%EC%9D%98%20%EB%B9%84%EA%B0%80%20%EC%A0%84%EC%8A%B9%EC%95%A0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}]}"}, {"id": "people", "name": "인물 열전", "enabled": false, "locked": false, "data": "{\"kind\": \"people\", \"version\": 1, \"title\": \"인물 열전\", \"subtitle\": \"군산이 낳은 사람 57명\", \"items\": [{\"name\": \"채만식\", \"role\": \"소설가\", \"oneLine\": \"군산을 무대로 한 『탁류』를 썼습니다. 시내에 문학관이 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EB%A7%8C%EC%8B%9D\"}, \"years\": \"1902–1950\", \"imageUrl\": \"/assets/mirror/d902ef61234bd897.png\"}, {\"name\": \"고은\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 60년 넘게 시를 써 온 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%EC%9D%80\"}, \"years\": \"1933–\", \"imageUrl\": \"/s/stay/img/people/%EA%B3%A0%EC%9D%80.jpg\", \"imageCredit\": \"사진 Matěj Baťha · CC BY-SA 3.0\"}, {\"name\": \"김선기\", \"role\": \"언어학자\", \"oneLine\": \"호는 무돌. 한글 연구에 힘쓴 언어학자이자 교육자입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%84%A0%EA%B8%B0_%28%EC%96%B8%EC%96%B4%EC%B2%A0%ED%95%99%EC%9E%90%29\"}, \"years\": \"1907–1992\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EC%84%A0%EA%B8%B0.jpg\", \"imageCredit\": \"사진 미상Unknown author · Public domain\"}, {\"name\": \"김수미\", \"role\": \"배우\", \"oneLine\": \"「전원일기」 일용 엄니로 오래 기억되는 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%88%98%EB%AF%B8\"}, \"years\": \"1949–2024\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EC%88%98%EB%AF%B8.png\", \"imageCredit\": \"사진 Seoul Broadcasting System · CC BY 3.0\"}, {\"name\": \"송새벽\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%86%A1%EC%83%88%EB%B2%BD\"}, \"years\": \"1979–\", \"imageUrl\": \"/s/stay/img/people/%EC%86%A1%EC%83%88%EB%B2%BD.png\", \"imageCredit\": \"사진 틈새TV · CC BY 3.0\"}, {\"name\": \"장신영\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%A5%EC%8B%A0%EC%98%81\"}, \"years\": \"1984–\", \"imageUrl\": \"/s/stay/img/people/%EC%9E%A5%EC%8B%A0%EC%98%81.jpg\", \"imageCredit\": \"사진 acrofan.com · CC BY-SA 3.0\"}, {\"name\": \"이서원\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%84%9C%EC%9B%90\"}, \"years\": \"1997–\", \"imageUrl\": \"/s/stay/img/people/%EC%9D%B4%EC%84%9C%EC%9B%90.png\", \"imageCredit\": \"사진 KBS 뷰 · CC BY 3.0\"}, {\"name\": \"병헌\", \"role\": \"배우\", \"oneLine\": \"그룹 틴탑 활동을 거쳐 배우로 옮겼습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B3%91%ED%97%8C\"}, \"years\": \"1993–\", \"imageUrl\": \"/s/stay/img/people/%EB%B3%91%ED%97%8C.jpg\", \"imageCredit\": \"사진 뉴스인스타 · CC BY 3.0\"}, {\"name\": \"정혁\", \"role\": \"모델\", \"oneLine\": \"2015년 서울패션위크로 데뷔한 모델 겸 방송인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%ED%98%81_%28%EB%AA%A8%EB%8D%B8%29\"}, \"years\": \"1991–\", \"imageUrl\": \"/s/stay/img/people/%EC%A0%95%ED%98%81.jpg\", \"imageCredit\": \"사진 파리게이츠 · CC BY 3.0\"}, {\"name\": \"박화요비\", \"role\": \"가수\", \"oneLine\": \"R&B 를 주로 부르는 가수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%ED%99%94%EC%9A%94%EB%B9%84\"}, \"years\": \"1982–\", \"imageUrl\": \"/s/stay/img/people/%EB%B0%95%ED%99%94%EC%9A%94%EB%B9%84.jpg\", \"imageCredit\": \"사진 Jinho.Jung · CC BY-SA 2.0\"}, {\"name\": \"고건\", \"role\": \"정치인\", \"oneLine\": \"제30·35대 국무총리를 지냈습니다. 아버지는 철학자 고형곤입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%EA%B1%B4\"}, \"years\": \"1938–\", \"imageUrl\": \"/s/stay/img/people/%EA%B3%A0%EA%B1%B4.jpg\", \"imageCredit\": \"사진 서울특별시 소방재난본부 · CC BY-SA 4.0\"}, {\"name\": \"김관영\", \"role\": \"정치인\", \"oneLine\": \"전북특별자치도지사입니다. 회계사·변호사를 거쳤습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EA%B4%80%EC%98%81\"}, \"years\": \"1969–\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EA%B4%80%EC%98%81.jpg\", \"imageCredit\": \"사진 대한민국 국회 의장비서실 · KOGL Type 1\"}, {\"name\": \"함운경\", \"role\": \"정치인\", \"oneLine\": \"1980년대 학생운동가 출신입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%A8%EC%9A%B4%EA%B2%BD\"}, \"years\": \"1964–\", \"imageUrl\": \"/s/stay/img/people/%ED%95%A8%EC%9A%B4%EA%B2%BD.jpg\", \"imageCredit\": \"사진 Teakim11 · CC BY-SA 4.0\"}, {\"name\": \"차우찬\", \"role\": \"야구 선수\", \"oneLine\": \"투수로 뛰었고 지금은 야구 해설위원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B0%A8%EC%9A%B0%EC%B0%AC\"}, \"years\": \"1987–\", \"imageUrl\": \"/s/stay/img/people/%EC%B0%A8%EC%9A%B0%EC%B0%AC.jpg\", \"imageCredit\": \"사진 원래 올린이는 한국어 위키백과의 Realidad y Illusion · CC BY-SA 2.5\"}, {\"name\": \"이진영\", \"role\": \"야구 선수\", \"oneLine\": \"외야수로 뛰었고 지금은 코치입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A7%84%EC%98%81_%281980%EB%85%84%29\"}, \"years\": \"1980–\", \"imageUrl\": \"/s/stay/img/people/%EC%9D%B4%EC%A7%84%EC%98%81.jpg\", \"imageCredit\": \"사진 itsmygabble · CC BY 4.0\"}, {\"name\": \"오지환\", \"role\": \"야구 선수\", \"oneLine\": \"LG 트윈스 내야수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%98%A4%EC%A7%80%ED%99%98\"}, \"years\": \"1990–\", \"imageUrl\": \"/s/stay/img/people/%EC%98%A4%EC%A7%80%ED%99%98.png\", \"imageCredit\": \"사진 OSEN SPORTS · CC BY-SA 3.0\"}, {\"name\": \"최서우\", \"role\": \"스키점프 선수\", \"oneLine\": \"1998년부터 국가대표로 뛴 스키점프 선수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B5%9C%EC%84%9C%EC%9A%B0\"}, \"years\": \"1982–\", \"imageUrl\": \"/s/stay/img/people/%EC%B5%9C%EC%84%9C%EC%9A%B0.jpg\", \"imageCredit\": \"사진 Clément Bucco-Lechat · CC BY-SA 3.0\"}, {\"name\": \"문호준\", \"role\": \"프로게이머\", \"oneLine\": \"카트라이더 리그 최다 우승자입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%AC%B8%ED%98%B8%EC%A4%80\"}, \"years\": \"1997–\", \"imageUrl\": \"/s/stay/img/people/%EB%AC%B8%ED%98%B8%EC%A4%80.jpg\", \"imageCredit\": \"사진 acrofan.com · CC BY-SA 3.0\"}, {\"name\": \"이병훈\", \"role\": \"시인\", \"oneLine\": \"옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EB%B3%91%ED%9B%88_%28%EC%8B%9C%EC%9D%B8%29\"}, \"years\": \"1925–2009\"}, {\"name\": \"심호택\", \"role\": \"시인\", \"oneLine\": \"민중의 삶을 시로 옮긴 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%8B%AC%ED%98%B8%ED%83%9D\"}, \"years\": \"1947–2010\"}, {\"name\": \"문효치\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 1966년 신춘문예로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%AC%B8%ED%9A%A8%EC%B9%98\"}, \"years\": \"1943–\"}, {\"name\": \"이연주\", \"role\": \"시인\", \"oneLine\": \"짧게 활동하고 떠난 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%97%B0%EC%A3%BC_%28%EC%8B%9C%EC%9D%B8%29\"}, \"years\": \"1953–1992\"}, {\"name\": \"주하림\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 2009년 창비신인시인상으로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A3%BC%ED%95%98%EB%A6%BC\"}}, {\"name\": \"고형곤\", \"role\": \"철학자\", \"oneLine\": \"선(禪) 연구로 알려진 철학자이자 정치가입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%ED%98%95%EA%B3%A4\"}, \"years\": \"1906–2004\"}, {\"name\": \"반석평\", \"role\": \"조선 문신\", \"oneLine\": \"노비 출신으로 문과에 급제해 판서에 오른 조선 중종 때 문신입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%98%EC%84%9D%ED%8F%89\"}, \"years\": \"1472–1540\"}, {\"name\": \"장재호\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%A5%EC%9E%AC%ED%98%B8_%28%EB%B0%B0%EC%9A%B0%29\"}, \"years\": \"1986–\"}, {\"name\": \"강민지\", \"role\": \"배우 · 모델\", \"oneLine\": \"모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B0%95%EB%AF%BC%EC%A7%80_%28%EB%B0%B0%EC%9A%B0%29\"}, \"years\": \"1996–\"}, {\"name\": \"김혜선\", \"role\": \"희극인\", \"oneLine\": \"군산 출신 희극 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%ED%98%9C%EC%84%A0_%28%ED%9D%AC%EA%B7%B9%EC%9D%B8%29\"}, \"years\": \"1983–\"}, {\"name\": \"이옥주\", \"role\": \"희극인\", \"oneLine\": \"군산중앙여고를 나와 1988년 MBC 개그콘테스트 대상을 받았습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%98%A5%EC%A3%BC_%28%ED%9D%AC%EA%B7%B9%EC%9D%B8%29\"}, \"years\": \"1969–\"}, {\"name\": \"박윤경\", \"role\": \"가수\", \"oneLine\": \"군산 출신 가수 겸 기업인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EC%9C%A4%EA%B2%BD_%28%EA%B0%80%EC%88%98%29\"}, \"years\": \"1970–\"}, {\"name\": \"하준\", \"role\": \"가수\", \"oneLine\": \"군산 출신 가수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%98%EC%A4%80_%28%EA%B0%80%EC%88%98%29\"}, \"years\": \"1965–\"}, {\"name\": \"강봉균\", \"role\": \"정치인\", \"oneLine\": \"재정경제부 장관과 3선 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B0%95%EB%B4%89%EA%B7%A0_%28%EC%A0%95%EC%B9%98%EC%9D%B8%29\"}, \"years\": \"1943–2017\"}, {\"name\": \"양일동\", \"role\": \"정치인\", \"oneLine\": \"옥구군 서수면 출신으로 야당 정치인으로 활동했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%96%91%EC%9D%BC%EB%8F%99\"}, \"years\": \"1912–1980\"}, {\"name\": \"채영석\", \"role\": \"정치인\", \"oneLine\": \"군산에서 3선 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EC%98%81%EC%84%9D\"}, \"years\": \"1934–2006\"}, {\"name\": \"신영대\", \"role\": \"정치인\", \"oneLine\": \"군산 지역구 국회의원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%8B%A0%EC%98%81%EB%8C%80\"}, \"years\": \"1968–\"}, {\"name\": \"김의겸\", \"role\": \"언론인 · 정치인\", \"oneLine\": \"한겨레 기자를 거쳐 청와대 대변인과 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%9D%98%EA%B2%B8\"}, \"years\": \"1963–\"}, {\"name\": \"임병찬\", \"role\": \"의병장\", \"oneLine\": \"최익현과 함께 거병한 구한말 의병장으로, 단식 끝에 순국했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%84%EB%B3%91%EC%B0%AC\"}, \"years\": \"1851–1916\"}, {\"name\": \"이수현\", \"role\": \"독립운동가\", \"oneLine\": \"호는 산남. 군산 출신 독립운동가입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%88%98%ED%98%84_%281895%EB%85%84%29\"}, \"years\": \"1895–1980\"}, {\"name\": \"전진\", \"role\": \"비전향 장기수\", \"oneLine\": \"군산 출신으로 오래 수감 생활을 했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%84%EC%A7%84_%281923%EB%85%84%29\"}, \"years\": \"1923–2012\"}, {\"name\": \"이길여\", \"role\": \"의료인 · 교육자\", \"oneLine\": \"가천대학교 길병원을 세우고 가천대 총장을 맡고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EA%B8%B8%EC%97%AC\"}, \"years\": \"1932–\"}, {\"name\": \"최길선\", \"role\": \"기업인\", \"oneLine\": \"현대중공업 회장을 지낸 조선업 경영인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B5%9C%EA%B8%B8%EC%84%A0\"}, \"years\": \"1946–\"}, {\"name\": \"은성수\", \"role\": \"공무원\", \"oneLine\": \"제7대 금융위원회 위원장을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%80%EC%84%B1%EC%88%98_%28%EA%B3%B5%EB%AC%B4%EC%9B%90%29\"}, \"years\": \"1961–\"}, {\"name\": \"채금석\", \"role\": \"축구 선수\", \"oneLine\": \"일제강점기 경성 축구단 선수로 경평축구대항전에 나섰습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EA%B8%88%EC%84%9D\"}, \"years\": \"1904–1995\"}, {\"name\": \"박경완\", \"role\": \"야구 선수\", \"oneLine\": \"SK 와이번스 포수로 한국 야구의 대표적인 포수였습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EA%B2%BD%EC%99%84\"}, \"years\": \"1972–\"}, {\"name\": \"조계현\", \"role\": \"야구 선수\", \"oneLine\": \"해태 타이거즈 투수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A1%B0%EA%B3%84%ED%98%84\"}, \"years\": \"1964–\"}, {\"name\": \"정명원\", \"role\": \"야구 선수\", \"oneLine\": \"현대 유니콘스 투수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%EB%AA%85%EC%9B%90\"}, \"years\": \"1966–\"}, {\"name\": \"조규제\", \"role\": \"야구 선수\", \"oneLine\": \"왼손 투수로 뛰고 지금은 투수코치입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A1%B0%EA%B7%9C%EC%A0%9C\"}, \"years\": \"1967–\"}, {\"name\": \"이천웅\", \"role\": \"야구 선수\", \"oneLine\": \"LG 트윈스 외야수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%B2%9C%EC%9B%85\"}, \"years\": \"1988–\"}, {\"name\": \"노상래\", \"role\": \"축구 선수\", \"oneLine\": \"공격수로 뛰었고 지도자로 일하고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%85%B8%EC%83%81%EB%9E%98\"}, \"years\": \"1970–\"}, {\"name\": \"노수진\", \"role\": \"축구 선수\", \"oneLine\": \"공격수로 뛰었고 지금은 체육 교사입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%85%B8%EC%88%98%EC%A7%84\"}, \"years\": \"1962–\"}, {\"name\": \"유동우\", \"role\": \"축구 선수\", \"oneLine\": \"수비수로 뛰었고 대학 축구부 감독을 맡고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9C%A0%EB%8F%99%EC%9A%B0\"}, \"years\": \"1968–\"}, {\"name\": \"이정효\", \"role\": \"축구 감독\", \"oneLine\": \"풀백으로 뛰었고 지금은 프로팀 감독입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A0%95%ED%9A%A8\"}, \"years\": \"1975–\"}, {\"name\": \"박성현\", \"role\": \"양궁 선수\", \"oneLine\": \"군산 소룡초에서 활을 처음 잡아 올림픽 금메달을 땄습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EC%84%B1%ED%98%84_%28%EC%96%91%EA%B6%81_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1983–\"}, {\"name\": \"김광선\", \"role\": \"권투 선수\", \"oneLine\": \"1988년 서울 올림픽 복싱 플라이급 금메달리스트입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EA%B4%91%EC%84%A0_%28%EA%B6%8C%ED%88%AC_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1964–\"}, {\"name\": \"정경미\", \"role\": \"유도 선수\", \"oneLine\": \"군산 출신 유도 선수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%EA%B2%BD%EB%AF%B8_%28%EC%9C%A0%EB%8F%84_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1985–\"}, {\"name\": \"전미라\", \"role\": \"테니스 선수\", \"oneLine\": \"테니스 선수로 뛰었고 지금은 해설위원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%84%EB%AF%B8%EB%9D%BC\"}, \"years\": \"1978–\"}, {\"name\": \"한왕용\", \"role\": \"산악인\", \"oneLine\": \"히말라야 8000m 14좌를 세계 11번째로 완등했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%9C%EC%99%95%EC%9A%A9\"}, \"years\": \"1966–\"}]}"}, {"id": "chronicle", "name": "시간의 골목", "enabled": false, "locked": false, "data": "{\"kind\":\"chronicle\",\"version\":1,\"items\":[{\"year\":1380,\"title\":\"진포대첩\",\"summary\":\"최무선의 화포가 왜구 함대를 무너뜨린 해. 군산 해양사의 출발점으로 꼽힙니다.\",\"place\":\"진포시비공원\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/9b073d4a1b5fb62c.jpg\"},{\"year\":1899,\"title\":\"군산 개항\",\"summary\":\"항구가 열리며 도시의 성격이 한 번에 바뀝니다.\",\"place\":\"군산 내항\",\"turning\":true,\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\"},{\"year\":1908,\"title\":\"구 군산세관 본관 준공\",\"summary\":\"지금도 서 있는 벽돌 건물입니다. 개항기 행정의 자리였습니다.\",\"place\":\"구 군산세관 본관\",\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/22af0bff45e5fce8.jpg\"},{\"year\":1926,\"title\":\"내항 부잔교 축조\",\"summary\":\"조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로였습니다.\",\"place\":\"군산 내항 부잔교\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\"},{\"year\":1937,\"title\":\"『탁류』 연재 시작\",\"summary\":\"채만식이 이 도시를 소설의 무대로 세웁니다.\",\"place\":\"채만식문학관\",\"verified\":\"확인\",\"source\":{\"name\":\"군산시 채만식문학관\",\"url\":\"https://www.gunsan.go.kr/chae/\"},\"imageUrl\":\"/assets/mirror/8043bad52d95a84b.jpg\"},{\"year\":1990,\"title\":\"금강하굿둑 준공\",\"summary\":\"강과 바다의 경계가 사람 손으로 그어집니다.\",\"place\":\"금강하굿둑\",\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/aaf32d2c8e5faffb.jpg\"},{\"year\":2010,\"title\":\"새만금 방조제 개통\",\"summary\":\"간척으로 해안선이 다시 그려집니다.\",\"place\":\"새만금 방조제\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"대한민국 구석구석\",\"url\":\"https://korean.visitkorea.or.kr/\"},\"imageUrl\":\"/assets/mirror/487643cdcfd19fdb.jpg\"}],\"title\":\"시간의 골목\",\"subtitle\":\"이 도시를 만든 해들\"}"}, {"id": "postcard", "name": "오늘의 엽서", "enabled": false, "locked": false, "data": "{\"kind\":\"postcard\",\"version\":1,\"items\":[{\"line\":\"부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.\",\"hashtags\":[\"#군산\",\"#내항\",\"#부잔교\"],\"place\":\"군산 내항\",\"postmark\":\"군산 內港\",\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\"},{\"line\":\"짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.\",\"hashtags\":[\"#군산짬뽕\",\"#항구도시\"],\"place\":\"군산 중앙로\",\"postmark\":\"군산 中央\",\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/149c18951bdcaff8.jpg\"},{\"line\":\"단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.\",\"hashtags\":[\"#이성당\",\"#오래된가게\"],\"place\":\"이성당\",\"postmark\":\"군산 中央\",\"verified\":\"확인\",\"source\":{\"name\":\"대한민국 구석구석\",\"url\":\"https://korean.visitkorea.or.kr/\"},\"imageUrl\":\"/assets/mirror/37c90494d28478e2.png\"},{\"line\":\"섬과 섬 사이를 다리로 건너면, 바다가 길이 된다.\",\"hashtags\":[\"#고군산군도\",\"#선유도\"],\"place\":\"고군산군도\",\"postmark\":\"고군산\",\"verified\":\"확인필요\",\"source\":{\"name\":\"대한민국 구석구석\",\"url\":\"https://korean.visitkorea.or.kr/\"},\"imageUrl\":\"/assets/mirror/6ded14f21a17894b.jpg\"}],\"title\":\"오늘의 엽서\",\"subtitle\":\"그대로 붙여 쓰는 한 문장\"}"}, {"id": "quiz", "name": "뒤집어 보는 질문", "enabled": false, "locked": false, "data": "{\"kind\": \"quiz\", \"version\": 1, \"items\": [{\"question\": \"부잔교는 왜 물 위에 뜨도록 만들었을까?\", \"hint\": \"서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.\", \"topic\": \"군산 내항\", \"level\": \"초등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"『탁류』의 “탁류”는 무엇을 가리키는 말일까?\", \"hint\": \"금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.\", \"topic\": \"채만식문학관\", \"level\": \"중등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"question\": \"해망굴은 무엇을 위해 뚫린 굴일까?\", \"hint\": \"근대 교통로였고, 전쟁기에는 다른 쓰임도 있었습니다. 굴 하나에 두 시대가 겹칩니다.\", \"topic\": \"해망굴\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"고군산군도의 ‘고(古)’ 는 왜 붙었을까?\", \"hint\": \"지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.\", \"topic\": \"고군산군도\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"뒤집어 보는 질문\", \"subtitle\": \"뒤집으면 힌트가 나옵니다\"}"}, {"id": "faq", "name": "자주 묻는 질문", "enabled": true, "locked": false}, {"id": "rules", "name": "이용 규정", "enabled": true, "locked": false}, {"id": "weather", "name": "날씨", "enabled": true, "locked": false}, {"id": "planner", "name": "계절별 추천 하루", "enabled": false, "locked": false, "data": "{\"kind\":\"planner\",\"version\":1,\"title\":\"계절별 추천 하루\",\"subtitle\":\"지금 계절에 맞는 코스만 보여 드립니다\",\"items\":[{\"name\":\"탁류길 코스\",\"audience\":\"걷기 좋아하는 손님\",\"why\":\"소설 『탁류』가 지나간 자리를 그대로 따라 걷습니다.\",\"startTime\":\"10:00\",\"stops\":[{\"name\":\"채만식문학관\",\"minutes\":12,\"note\":\"금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.\",\"searchQuery\":\"군산 채만식문학관\",\"imageUrl\":\"/assets/mirror/8043bad52d95a84b.jpg\",\"latitude\":36.0085981064,\"longitude\":126.7578853293},{\"name\":\"군산 내항 부잔교\",\"minutes\":9,\"note\":\"조수에 오르내리던 뜬다리. 이 코스의 중심입니다.\",\"searchQuery\":\"군산 내항 부잔교\",\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\",\"latitude\":35.9892997236,\"longitude\":126.7174508158},{\"name\":\"째보선창\",\"minutes\":7,\"note\":\"항구 노동의 기억이 남은 선창입니다.\",\"searchQuery\":\"군산 째보선창\",\"latitude\":35.9875345,\"longitude\":126.7199875},{\"name\":\"초원사진관\",\"minutes\":11,\"note\":\"영화가 남기고 간 자리입니다.\",\"searchQuery\":\"군산 초원사진관\",\"imageUrl\":\"/assets/mirror/d5433fe48de3da05.jpg\",\"latitude\":35.9877369536,\"longitude\":126.7083826065},{\"name\":\"이성당\",\"minutes\":6,\"note\":\"코스의 끝. 단팥빵 하나로 반나절을 닫습니다.\",\"searchQuery\":\"군산 이성당\",\"imageUrl\":\"/assets/mirror/37c90494d28478e2.png\",\"latitude\":35.9870443303,\"longitude\":126.7111681428}],\"verified\":\"확인\",\"source\":{\"name\":\"군산 스탬프투어\",\"url\":\"https://www.gunsanstamp.kr/\"},\"season\":\"가을\",\"rank\":2},{\"name\":\"아리랑길 코스\",\"audience\":\"걷기 좋아하는 손님\",\"why\":\"개항기 건물만 골라 짧게 도는 길입니다.\",\"startTime\":\"10:00\",\"stops\":[{\"name\":\"구 군산세관 본관\",\"minutes\":8,\"note\":\"1908년에 지어진 벽돌 건물입니다.\",\"searchQuery\":\"구 군산세관 본관\",\"imageUrl\":\"/assets/mirror/22af0bff45e5fce8.jpg\",\"latitude\":35.9915087952,\"longitude\":126.7113016917},{\"name\":\"군산근대미술관\",\"minutes\":5,\"note\":\"옛 은행 금고가 그대로 남아 있습니다.\",\"searchQuery\":\"군산근대미술관\",\"imageUrl\":\"/assets/mirror/d8764592a03b0103.jpg\",\"latitude\":35.9899367776,\"longitude\":126.7127710457},{\"name\":\"해망굴\",\"minutes\":14,\"note\":\"굴 하나에 두 시대가 겹칩니다.\",\"searchQuery\":\"군산 해망굴\",\"imageUrl\":\"/assets/mirror/0a1e0ff50ca7ee37.jpg\",\"latitude\":35.9913180812,\"longitude\":126.7048496204}],\"verified\":\"확인필요\",\"source\":{\"name\":\"군산 스탬프투어\",\"url\":\"https://www.gunsanstamp.kr/\"},\"season\":\"봄\",\"rank\":1},{\"name\":\"개항장 골목 한 바퀴\",\"audience\":\"처음 온 손님\",\"why\":\"해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.\",\"startTime\":\"09:30\",\"stops\":[{\"name\":\"군산근대역사박물관\",\"minutes\":90,\"moveMinutes\":12,\"note\":\"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\"searchQuery\":\"군산근대역사박물관\",\"imageUrl\":\"/assets/mirror/af673cc75c0ed696.jpg\",\"latitude\":35.9908197098,\"longitude\":126.7121231556},{\"name\":\"초원사진관\",\"minutes\":30,\"moveMinutes\":10,\"note\":\"영화가 남기고 간 자리입니다.\",\"searchQuery\":\"군산 초원사진관\",\"imageUrl\":\"/assets/mirror/d5433fe48de3da05.jpg\",\"latitude\":35.9877369536,\"longitude\":126.7083826065},{\"name\":\"이성당\",\"minutes\":40,\"moveMinutes\":8,\"note\":\"단팥빵 하나로 오후를 엽니다.\",\"searchQuery\":\"군산 이성당\",\"imageUrl\":\"/assets/mirror/37c90494d28478e2.png\",\"latitude\":35.9870443303,\"longitude\":126.7111681428},{\"name\":\"경암동 철길마을\",\"minutes\":60,\"moveMinutes\":18,\"note\":\"해 질 무렵 빛이 가장 좋습니다.\",\"searchQuery\":\"군산 경암동 철길마을\",\"imageUrl\":\"/assets/mirror/6d59722cedddd566.jpg\",\"latitude\":35.9813520474,\"longitude\":126.7362330582}],\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"season\":\"가을\",\"rank\":1},{\"name\":\"비 오면 여기로\",\"audience\":\"장마철에 온 손님\",\"why\":\"실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.\",\"season\":\"여름\",\"startTime\":\"10:30\",\"stops\":[{\"name\":\"군산근대건축관\",\"minutes\":60,\"moveMinutes\":10,\"note\":\"옛 은행 건물 안이 통째로 전시입니다.\",\"searchQuery\":\"군산근대건축관\",\"imageUrl\":\"/assets/mirror/5000f0a4feb45788.jpg\",\"latitude\":35.9894201935,\"longitude\":126.7141598237},{\"name\":\"한일옥\",\"minutes\":60,\"moveMinutes\":8,\"note\":\"무국 한 그릇으로 몸을 데웁니다.\",\"searchQuery\":\"군산 한일옥\",\"latitude\":35.9874081251,\"longitude\":126.7082350808},{\"name\":\"째보선창 근처 카페\",\"minutes\":90,\"moveMinutes\":10,\"note\":\"비 그칠 때까지 앉아 있기 좋습니다.\",\"searchQuery\":\"군산 째보선창 카페\",\"latitude\":35.9875345,\"longitude\":126.7199875}],\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"rank\":1}]}"}], "look": {"radius": "0px", "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)", "texture": "repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)", "fontBody": "'Gowun Batang', 'Noto Serif KR', serif", "borderWidth": "2px", "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif", "sectionSpace": "4rem", "headingWeight": "400", "headingTracking": "0em"}}} \ No newline at end of file +{"schemaVersion": 1, "site": {"siteId": "e929e6b4-e502-4761-9786-0bb82178b375", "placeId": "66894a1b-a331-43f1-942c-0ae064a704d6", "status": 3, "origin": "https://web4ai.o2osolution.ai", "basePath": "/s/stay", "slug": "stay", "publishedAt": "2026-09-03T01:27:39.284442+00:00", "updatedAt": "2026-09-14T07:42:00+00:00", "version": 11}, "place": {"name": "스테이,머뭄", "category": 1, "roadAddress": "전북 군산시 절골길 18", "address": "전북 군산시 신흥동 63-18", "addressRegion": "전북", "addressLocality": "군산시", "addressSubLocality": null, "addressRegionCode": "KR-45", "phone": "0507-1497-0983", "latitude": 35.9864175, "longitude": 126.7061254, "regionCode": "52군산시", "kakaoPlaceId": null}, "facts": [{"key": "parking", "label": "주차 가능", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.678003+00:00", "verifiedAt": "2026-08-28T02:12:21.402109+00:00"}, {"key": "intro", "label": "숙소 소개", "value": "1920년대에 지어진 백 년 고택을 리모델링한 독채 펜션입니다. 히로쓰 가옥 후문 바로 옆이고, 침구는 매일 세탁·살균·건조합니다.", "unit": null, "type": "text", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.547669+00:00", "verifiedAt": "2026-08-28T02:12:21.922925+00:00"}, {"key": "wifi", "label": "와이파이", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-08-28T02:10:08.757070+00:00", "verifiedAt": "2026-08-28T02:12:22.356600+00:00"}, {"key": "check_out_time", "label": "체크아웃 시간", "value": "11:00", "unit": null, "type": "time", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "cancel_policy", "label": "취소·환불 규정", "value": "입실 당일 취소와 노쇼(No Show)는 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", "unit": null, "type": "text", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "cooking_allowed", "label": "취사 가능", "value": "true", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "pet_allowed", "label": "반려동물 동반", "value": "false", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "smoking", "label": "흡연 가능", "value": "false", "unit": null, "type": "bool", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "extra_person_fee", "label": "인원 추가 요금", "value": "20000", "unit": "원", "type": "number", "scope": "place", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "check_in_time", "label": "체크인 시간", "value": "15:00", "unit": null, "type": "time", "scope": "place", "status": 3, "sourceType": 1, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": null, "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T23:31:05.719599+00:00"}, {"key": "pickup_service", "label": "픽업 서비스", "value": "false", "type": "bool", "scope": "place", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}, {"key": "bbq_available", "label": "바비큐 이용", "value": "false", "type": "bool", "scope": "place", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": false, "required": false, "unitId": null, "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "units": [{"unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "name": "A동(일본식 가정집 느낌으로 디딤돌마당과 예쁜정원)", "slug": "a동", "sortOrder": 0, "facts": [{"key": "room_type", "label": "객실 타입", "value": "독채", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "standard_capacity", "label": "기준 인원", "value": "2", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "max_capacity", "label": "최대 인원", "value": "4", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "weekday_price", "label": "주중 요금", "value": "198000", "unit": "원", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "room_intro", "label": "객실 소개", "value": "편안한 일본집에 와 있는 듯한 느낌의 공간입니다. 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다.", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "price_range", "label": "요금", "value": "198,000 ~ 350,000원", "type": "text", "scope": "unit", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": true, "required": false, "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "mediaIds": ["1330beb8-09d3-47fc-a410-f2e9598d13fe", "ad35769c-5299-4d39-b80d-5d88f1e1de14", "2fbcd37e-260f-4a4f-b00f-3e997e978c02", "d95857a0-44ad-422b-86de-00dee8aca08f"]}, {"unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "name": "B동(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)", "slug": "b동", "sortOrder": 1, "facts": [{"key": "room_type", "label": "객실 타입", "value": "독채", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "standard_capacity", "label": "기준 인원", "value": "2", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "max_capacity", "label": "최대 인원", "value": "4", "unit": "명", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": true, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "weekday_price", "label": "주중 요금", "value": "198000", "unit": "원", "type": "number", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": true, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "room_intro", "label": "객실 소개", "value": "아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다.", "unit": null, "type": "text", "scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", "critical": false, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-02T13:27:53.241391+00:00", "verifiedAt": "2026-09-02T13:27:53.241391+00:00"}, {"key": "price_range", "label": "요금", "value": "198,000 ~ 350,000원", "type": "text", "scope": "unit", "unit": null, "status": 3, "sourceType": 3, "sourceUrl": "https://m.place.naver.com/place/1133638931/home", "critical": true, "required": false, "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", "collectedAt": "2026-09-04T00:00:00+00:00", "verifiedAt": "2026-09-04T00:00:00+00:00"}], "mediaIds": ["5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", "4981b572-10e2-4138-ba1d-a5d7defb4b4a", "83a2543f-4366-4427-b694-a7b1968a61cf"]}], "media": [{"mediaId": "1330beb8-09d3-47fc-a410-f2e9598d13fe", "url": "/assets/mirror/ddc9f804f4ef83b5.jpg", "alt": "목재 서까래 천장과 미닫이창, 테이블이 있는 거실 공간", "category": "거실", "width": null, "height": null, "isPrimary": true, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", "unitId": null}, {"mediaId": "ad35769c-5299-4d39-b80d-5d88f1e1de14", "url": "/assets/mirror/6d0c447b6a0c32af.jpg", "alt": "소파와 탁자, 벽난로 조명이 있는 서까래 천장의 거실", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", "unitId": null}, {"mediaId": "83a2543f-4366-4427-b694-a7b1968a61cf", "url": "/assets/mirror/2d94158a48480fcd.jpg", "alt": "싱크대와 냉장고, 전자레인지가 구비된 실내 주방 공간", "category": "주방", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", "unitId": null}, {"mediaId": "990a89ed-9517-426c-8c17-fd9bf3c1a44d", "url": "/assets/mirror/d364f605223524bd.jpg", "alt": "주방 싱크대 앞에서 포즈를 취하고 있는 여성의 모습", "category": "주방", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", "unitId": null}, {"mediaId": "5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", "url": "/assets/mirror/e0ed8d1a27e09320.jpg", "alt": "벽면에 설치된 그림 화면과 벽난로 조명 장식", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", "unitId": null}, {"mediaId": "2fbcd37e-260f-4a4f-b00f-3e997e978c02", "url": "/assets/mirror/aa69a633f8e3a631.jpg", "alt": "창밖으로 조명을 비춘 정원 나무와 석등이 보이는 풍경", "category": "전망", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", "unitId": null}, {"mediaId": "d95857a0-44ad-422b-86de-00dee8aca08f", "url": "/assets/mirror/7095220ca156e25e.jpg", "alt": "목재 서까래 천장에 설치된 조형적인 디자인의 조명 기구", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", "unitId": null}, {"mediaId": "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", "url": "/assets/mirror/caa5e076e7d56ec2.jpg", "alt": "실내 벽면 홈 공간에 놓인 흰색 화병과 식물 장식", "category": "거실", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", "unitId": null}, {"mediaId": "4981b572-10e2-4138-ba1d-a5d7defb4b4a", "url": "/assets/mirror/8e2ec99c433b6fe8.jpg", "alt": "실내 창문을 통해 바라본 푸른 하늘과 푸른 나무 풍경", "category": "전망", "width": null, "height": null, "isPrimary": false, "sourceType": 3, "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", "unitId": null}], "faqs": [{"faqId": "8d48a94d-1a19-456f-b1a8-4144ddcb2186", "question": "체크인과 체크아웃 시간은 언제인가요?", "answer": "체크인은 15:00부터이며, 체크아웃은 11:00까지입니다.", "status": 3, "sourceType": 4, "sortOrder": 0}, {"faqId": "8f14678b-3589-42e9-8896-d214c0093784", "question": "객실 기준 인원과 최대 인원, 인원 추가 요금은 어떻게 되나요?", "answer": "A동과 B동 모두 기준 인원은 2명이며, 최대 4명까지 투숙하실 수 있습니다. 인원 추가 요금은 20,000원입니다.", "status": 3, "sourceType": 4, "sortOrder": 1}, {"faqId": "a13785c0-a627-44b5-b593-fac39712d6d4", "question": "객실 내 취사가 가능한가요?", "answer": "주방에서 간단한 조리는 가능합니다. 다만 생선구이·고기구이·튀김처럼 냄새가 잘 빠지지 않는 조리는 하실 수 없고, 객실 안에서 직화로 굽는 방식도 허용되지 않습니다. 그릴·숯·전기 전열기구 등 개인 취사도구는 반입이 금지되어 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 2}, {"faqId": "0eb535db-135f-484b-b0a4-1323744ace75", "question": "주차와 와이파이 이용이 가능한가요?", "answer": "네, 주차와 와이파이 모두 이용 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 3}, {"faqId": "cc268677-ad26-4685-a6a6-10a293658591", "question": "반려동물 동반이나 실내 흡연이 가능한가요?", "answer": "반려동물 동반은 불가하며, 전 구역 흡연이 불가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 4}, {"faqId": "4b8ac158-7648-4b2c-aa1a-9c6ca99b1223", "question": "A동과 B동은 무엇이 다른가요?", "answer": "A동은 편안한 일본집에 와 있는 듯한 느낌의 공간이고, B동은 아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 두 동 모두 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있으며, 기준 2인 · 최대 4인으로 같습니다. 침대 구성 등 객실 안 자세한 사항은 예약 채널이나 전화로 확인해 주세요.", "status": 3, "sourceType": 4, "sortOrder": 5}, {"faqId": "8bd75b48-49c3-475f-99c1-f5e64f49bedd", "question": "객실 요금은 얼마인가요?", "answer": "A동과 B동 모두 198,000원 ~ 350,000원입니다. 날짜와 시즌에 따라 달라지므로 정확한 금액은 예약 채널에서 확인해 주세요. 기준 인원(2인)을 넘으면 1인당 20,000원이 더해집니다.", "status": 3, "sourceType": 4, "sortOrder": 6}, {"faqId": "7b2bbfeb-761c-4a15-b410-43e18a69bb9e", "question": "예약 취소 및 환불 규정은 어떻게 되나요?", "answer": "입실 당일 취소와 노쇼(No Show)의 경우 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", "status": 3, "sourceType": 4, "sortOrder": 7}, {"faqId": "20e80f4d-322a-46ac-90b1-21fb6669b6b0", "question": "스테이,머뭄은 어떤 숙소인가요?", "answer": "스테이,머뭄은 전북 군산시 절골길 18(신흥동)에 있는 독채 숙소입니다. A동은 일본식 가정집 느낌, B동은 현대식 모던 스타일로 두 동이 있고, 편안한 침구와 좋은 위치를 내세운 곳입니다.", "status": 3, "sourceType": 4, "sortOrder": 8}, {"faqId": "724aeac2-46b1-4d6d-9748-892b5988a178", "question": "군산 독채 펜션을 찾는데, 여기도 독채인가요?", "answer": "네, 스테이,머뭄은 A동과 B동 모두 독채입니다. 동마다 독립된 정원과 창고형 카페 공간이 딸려 있고, 주방이 있어 취사도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 9}, {"faqId": "a50f8b18-1a2b-4025-ab0c-00d8ebcc9e8f", "question": "위치가 어디인가요?", "answer": "스테이,머뭄은 전북 군산시 절골길 18(신흥동 63-18)에 있습니다. 바로 옆 13m 거리에 군산 신흥동 일본식가옥(히로쓰 가옥)이 있는 원도심 골목 안입니다. 문의는 0507-1497-0983입니다.", "status": 3, "sourceType": 4, "sortOrder": 10}, {"faqId": "7bac199e-f095-4b44-acf6-c94d68181fc8", "question": "예약은 어디서 하나요?", "answer": "스테이,머뭄은 네이버 플레이스에서 숙소 정보를 확인하실 수 있고, 전화 0507-1497-0983으로도 문의하실 수 있습니다. 입실 당일 취소와 노쇼는 숙박요금의 100%가 부과되니 일정을 확인하고 예약해 주세요.", "status": 3, "sourceType": 4, "sortOrder": 11}, {"faqId": "2059ea18-e444-4b2f-97df-ac8ada6e72b0", "question": "커플 둘이 묵기 좋은 2인 독채인가요?", "answer": "네, 스테이,머뭄 A동과 B동 모두 기준 인원 2인의 독채입니다. 두 동을 각각 한 팀만 쓰고, 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 12}, {"faqId": "b3950b7b-935d-47a5-8119-67afb028a5ce", "question": "일본식 가옥 같은 숙소를 찾는데 여기가 맞나요?", "answer": "스테이,머뭄 A동이 일본식 가정집 느낌으로 꾸민 독채입니다. 디딤돌 마당과 독립된 정원, 창고형 카페 공간이 딸려 있습니다. B동은 같은 마당 안에 있는 현대식 모던 스타일 독채입니다.", "status": 3, "sourceType": 4, "sortOrder": 13}, {"faqId": "78fcc01a-099f-4e7d-a731-d93d18573f55", "question": "조용하고 프라이빗한 숙소인가요?", "answer": "스테이,머뭄은 A동·B동 두 동뿐인 독채 숙소라 다른 손님과 건물을 나눠 쓰지 않습니다. 두 동 모두 독립된 정원을 따로 두고 있고, 전 구역 금연입니다.", "status": 3, "sourceType": 4, "sortOrder": 14}, {"faqId": "feb316f6-f0b5-419c-83e1-45fdec718024", "question": "차 없이 걸어서 근대문화거리까지 갈 수 있나요?", "answer": "스테이,머뭄에서 히로쓰 가옥은 13m, 초원사진관 248m, 군산 영화의 거리 318m, 군산근대미술관 708m, 옛 군산세관 728m, 근대건축관(구 조선은행 군산지점) 788m 거리입니다. 원도심 근대 건축물이 숙소를 중심으로 1km 안에 모여 있습니다. 주차도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 15}, {"faqId": "681b921e-d15f-48af-8378-21e7754f500f", "question": "이성당이나 근처 맛집까지 얼마나 되나요?", "answer": "스테이,머뭄에서 이성당까지는 455m입니다. 그 밖에 요리주점 도란 135m, 베이커리 물밀소 297m, 백년가게 국밥집 일흥옥 300m, 명월갈비 351m, 꽃게장 한주옥 430m로 모두 가까이 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 16}, {"faqId": "92c2e8b2-4a62-4b11-8898-7d75fd6bc6f7", "question": "마당이나 정원이 따로 있나요?", "answer": "네. A동과 B동 각각 독립된 아담한 정원과 창고형 CAFÉ 공간이 있습니다. A동에는 디딤돌 마당도 있습니다. 바다 전망은 아닙니다 — 마당과 거실에서 새소리를 들으며 쉬는 숙소입니다.", "status": 3, "sourceType": 4, "sortOrder": 17}, {"faqId": "2c5cfea9-e3c7-42e0-8353-87e5538f3f4e", "question": "선유도에 갈 건데 여기서 자도 될까요?", "answer": "네, 스테이,머뭄에서는 다리를 건너 고군산군도 선유도까지 차로 약 45분 걸립니다. 첫날은 군산 도심에서 저녁과 내항 밤 산책을, 이튿날 선유도와 장자도를 도는 1박 2일 코스를 추천 일정으로 안내하고 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 18}, {"faqId": "bab88a71-1cce-40cb-ad7a-9e8d1fdd5ff9", "question": "1박 2일이면 어떻게 돌면 좋을까요?", "answer": "스테이,머뭄이 안내하는 1박 2일 코스는 첫날 오후 체크인 후 빈해원에서 저녁, 군산 내항 밤 산책으로 마무리하고, 이튿날 선유도와 장자도를 도는 일정입니다. 걷는 코스를 원하시면 채만식문학관·내항 부잔교·째보선창·초원사진관·이성당으로 이어지는 반나절 탁류길 코스도 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 19}, {"faqId": "55288610-291a-4feb-95d8-29e3b4b605d7", "question": "군산이 처음인데 어디부터 보면 좋을까요?", "answer": "스테이,머뭄은 처음 오신 손님께 반나절 개항장 골목 코스를 안내합니다. 군산근대역사박물관에서 시작해 초원사진관, 이성당, 경암동 철길마을 순으로 도는 일정으로, 걷는 거리가 가장 짧은 코스입니다.", "status": 3, "sourceType": 4, "sortOrder": 20}, {"faqId": "a8fdcd07-70d4-4274-ac6f-1d0067beec2a", "question": "비 오는 날에는 뭘 하면 좋을까요?", "answer": "스테이,머뭄은 비 오는 날을 위한 실내 코스를 따로 안내합니다. 군산근대건축관에서 옛 은행 건물 전시를 보고, 한일옥에서 무국으로 몸을 데운 뒤, 째보선창 근처 카페에서 비가 그칠 때까지 앉아 있는 코스입니다. 숙소는 주방이 있어 취사도 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 21}, {"faqId": "4a20242b-d1ee-4c35-95a9-12bd0d90445c", "question": "바비큐나 고기를 구워 먹을 수 있나요?", "answer": "바비큐는 하실 수 없습니다. 화재 예방을 위해 객실 안에서 생선이나 고기를 굽는 직화 방식은 허용되지 않으며, 그릴·숯·전기 전열기구 등 개인적으로 준비해 오시는 취사도구도 반입이 금지되어 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 22}, {"faqId": "4b1b6de0-40ca-44fa-af35-cbd69eeebd35", "question": "픽업 서비스가 있나요?", "answer": "픽업 서비스는 지원하지 않습니다.", "status": 3, "sourceType": 4, "sortOrder": 23}, {"faqId": "648cf02d-d4ed-46ee-8aef-b3b6426e857e", "question": "건물이 오래된 집이라던데 언제 지어졌나요?", "answer": "1920년대에 지어진 적산가옥입니다. 백 년 된 고택을 리모델링해 2024년 5월 독채 펜션으로 문을 열었습니다. 바로 옆이 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥)입니다.", "status": 3, "sourceType": 4, "sortOrder": 24}, {"faqId": "015a1e83-a916-43fa-a6c1-95d7ece1817d", "question": "침구는 깨끗한가요?", "answer": "침구류는 매일 세탁하고 살균·건조합니다. 인테리어와 가구, 소품도 하나하나 준비해 두었습니다.", "status": 3, "sourceType": 4, "sortOrder": 25}, {"faqId": "10fa8fd2-7a04-4c0c-935c-dd354f7d3112", "question": "미성년자끼리 이용할 수 있나요?", "answer": "숙박업소는 법적으로 청소년 혼숙이 금지되어 있습니다. 미성년자는 보호자를 동반하셔야 이용하실 수 있습니다.", "status": 3, "sourceType": 4, "sortOrder": 26}, {"faqId": "48ec5a26-7e62-4e66-ae32-5917818bdce3", "question": "CCTV가 설치되어 있나요?", "answer": "고객님의 안전과 보안을 위해 대문 입구 쪽에 CCTV를 운영하고 있습니다. 객실 안에는 없습니다.", "status": 3, "sourceType": 4, "sortOrder": 27}, {"faqId": "9c3dadc6-bbc6-4f72-b4e8-c336dbfe11da", "question": "벌레가 나오지는 않나요?", "answer": "정기적으로 방역하고 있습니다. 다만 자연과 함께하는 공간이라 가끔 벌레나 이물질이 들어올 수 있으며, 이 사유로는 환불이 어려운 점 양해 부탁드립니다.", "status": 3, "sourceType": 4, "sortOrder": 28}, {"faqId": "1f83a416-f01d-4813-babe-011b039df462", "question": "예약 인원보다 사람이 늘면 어떻게 하나요?", "answer": "미리 숙소로 연락 주세요. 기준 인원(2인)을 넘으면 1인당 20,000원이 추가됩니다. 최대 인원(4인)을 넘으면 입실이 불가능할 수 있고, 그 사유로는 환불을 받으실 수 없습니다.", "status": 3, "sourceType": 4, "sortOrder": 29}, {"faqId": "8b0cfa98-9616-44ac-937d-dd5f0a8a4ca9", "question": "히로쓰 가옥 바로 옆이라던데 어떤 집인가요?", "answer": "스테이,머뭄은 1920년대에 지어진 집으로, 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥) 후문 바로 옆 13m 거리에 있습니다. 사장님은 이 집이 히로쓰 가옥 관리인이 살던 집이라고 전합니다. 백 년 된 고택을 리모델링해 2024년 5월 독채 펜션으로 문을 열었습니다.", "status": 3, "sourceType": 4, "sortOrder": 30}, {"faqId": "bbd98512-574e-4601-bd16-b48654aaf5d9", "question": "주요 관광지까지 걸어서 얼마나 걸리나요?", "answer": "초원사진관, 이성당, 군산 시간여행마을 등 주요 관광명소가 걸어서 최대 8분 거리입니다. 차 없이 도보 여행이 가능합니다.", "status": 3, "sourceType": 4, "sortOrder": 31}], "links": [{"channel": 3, "url": "https://m.place.naver.com/place/1133638931/home", "title": "스테이,머뭄 네이버 플레이스", "confirmed": true}, {"channel": 4, "url": "https://www.instagram.com/staymeomoom", "title": "스테이,머뭄 인스타그램", "confirmed": true}], "local": {"attractions": [{"name": "군산 신흥동 일본식가옥(히로쓰 가옥)", "category": "관광지", "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", "distanceText": "13m", "imageUrl": "/assets/mirror/f27b7440e2f1183f.jpg", "description": "군산 신흥동 일본식 가옥(구 히로쓰 가옥)은 일제강점기에 군산에서 포목점과 소규모 농장을 운영하며 군산부협의회 의원을 지낸 일본인이 건립한 일본식 2층 목조 가옥이다."}, {"name": "초원사진관", "category": "관광지", "searchQuery": "초원사진관", "distanceText": "248m", "imageUrl": "/assets/mirror/d5433fe48de3da05.jpg", "description": "초원사진관은 1998년 1월에 개봉한 영화 <8월의 크리스마스>의 촬영 장소로 유명한 곳이다. 이 영화는 배우 한석규와 심은하가 주연인 영화로 시한부 인생을 사는 사진사 정원(한석규)과 어느 날 그…"}, {"name": "군산 영화의 거리", "category": "관광지", "searchQuery": "군산 영화의 거리", "distanceText": "318m", "imageUrl": "/assets/mirror/6a0c445e6dd940af.jpg", "description": "시간과 공간을 초월한 특별한 경험을 할 수 있는 곳이다. 그 중에서 초원사진관은 영화 <8월의 크리스마스> 촬영지로 잘 알려진 관광명소이다."}, {"name": "우체통거리", "category": "관광지", "searchQuery": "우체통거리", "distanceText": "566m", "imageUrl": "/assets/mirror/43461e391f83b9e8.jpg", "description": "군산시 우체통거리는 2016년 도시재생사업으로 추진된 주민공모사업에 우체국 주변 주민들로 구성된 도란도란 공동체가 참여하면서 주민들이 직접 폐 우체통을 손질하고 그림을 그려 상가 앞에 설치하면서 조…"}, {"name": "군산근대미술관", "category": "관광지", "searchQuery": "군산근대미술관", "distanceText": "708m", "imageUrl": "/assets/mirror/d8764592a03b0103.jpg", "description": "18은행은 일본 나가사키에 본사를 두고 있던 은행으로, 숫자 18은 은행 설립인가 순서를 의미한다. 군산지점은 조선에서 7번째 지점으로 1907년에 설립되었다."}, {"name": "옛 군산세관", "category": "관광지", "searchQuery": "옛 군산세관", "distanceText": "728m", "imageUrl": "/assets/mirror/22af0bff45e5fce8.jpg", "description": "구 군산세관본관 건물이 완공된 것은 1908년으로, 일제강점기가 시작되기 전에 우리나라 정부가 만든 근대식 건물이다."}, {"name": "근대건축관 (구)조선은행군산지점", "category": "관광지", "searchQuery": "근대건축관 (구)조선은행군산지점", "distanceText": "788m", "imageUrl": "/assets/mirror/5000f0a4feb45788.jpg", "description": "1923년에 건립된 일제의 건물로 일제가 식민 지배를 위해 운영한 대표적인 금융시설이었다. 이 건물은 붉은 벽돌로 지은 4층 높이의 2층 건물로, 정면에 돌출된 현관을 중심으로 평아치를 5개 세우고…"}, {"name": "군산 시간여행마을", "category": "관광지", "searchQuery": "군산 시간여행마을", "distanceText": "1.1km", "imageUrl": "/assets/mirror/7ba545fdbc8ad273.jpg", "description": "군산은 근대사에서 수탈의 아픔과 이에 항거한 열정의 도시로, 일제강점기 근대 문화를 상상해볼 수 있는 문화재가 원도심을 중심으로 잘 보존되었다."}, {"name": "월명호수", "category": "관광지", "searchQuery": "월명호수", "distanceText": "1.6km", "imageUrl": "/assets/mirror/c8c6a295ae1c0810.jpg", "description": "월명공원의 품속에는 1912년에 시민에게 식수를 공급하기 위해 만든 제1수원지가 물안개를 뿜어내는 산 속의 호수로 변하여 산새와 작은 동물들이 목을 축이는 곳이 되었고 설림산 아래 천년고찰 은적사에…"}, {"name": "은적사(군산)", "category": "관광지", "searchQuery": "은적사(군산)", "distanceText": "2.3km", "imageUrl": "/assets/mirror/669abc2b08640d3e.jpg", "description": "은적사는 전라북도 내에 있는 사찰 가운데 가장 오래된 백제 시대 사찰이다. 천방사, 선림사 등으로도 불리었던 은적사는 대한불교 조계종 제17교구 본사인 금산사의 말사이다."}, {"name": "서천문화예술창작공간", "category": "관광지", "searchQuery": "서천문화예술창작공간", "distanceText": "2.6km", "imageUrl": "/assets/mirror/7f8f81d5eeb33f73.jpg", "description": "서천군 문화예술창작공간은 일제시대에 미곡창고로 사용된 건물로 이름에서 유추할 수 있듯이, 본래의 목적은 쌀을 보관하기 위해 세워진 건물이다."}, {"name": "경암동 철길마을", "category": "관광지", "searchQuery": "경암동 철길마을", "distanceText": "2.7km", "imageUrl": "/assets/mirror/6d59722cedddd566.jpg", "description": "경암동 철길마을은 1944년 전라북도 군산시 경암동에 준공하여 페이퍼 코리아 공장과 군산역을 연결하는 총연장 2.5km 철로 주변의 마을을 총괄하여 붙인 이름이다."}], "restaurants": [{"name": "도란", "category": "맛집", "searchQuery": "도란", "distanceText": "135m", "imageUrl": "/assets/mirror/12938240987c1f78.jpg", "description": "전라북도 군산시 월명동에 있는 요리주점이다. 와인과 함께 카페와 양식을 메뉴를 주로 다루고 있다. 대표 메뉴로는 감바스, 해산물 크림 스튜, 부채살 스테이크 등이 있다."}, {"name": "물밀소", "category": "맛집", "searchQuery": "물밀소", "distanceText": "297m", "imageUrl": "/assets/mirror/07600bb67b91dd15.jpg", "description": "월명동에 자리한 베이커리 물밀소는 신생 빵집으로 입소문이 자자하다. 붉은 벽돌의 아담한 외관을 가졌고 매장 내부에는 저온숙성한 반죽으로 빚어낸 건강한 빵들이 진열돼 있다."}, {"name": "일흥옥", "category": "맛집", "searchQuery": "일흥옥", "distanceText": "300m", "imageUrl": "/assets/mirror/3420ff7e357ab7ef.jpg", "description": "일흥옥은 군산 테디베어뮤지엄 인근에 위치한 국밥 전문점이다. 중소벤처기업부로부터 백년가게 인증을 받은 곳이다."}, {"name": "군산복집", "category": "맛집", "searchQuery": "군산복집", "distanceText": "305m", "description": "군산복집은 싱싱한 재료만을 사용하는 복요리 전문점으로 복탕과 아구탕은 해장하기에 좋으며, 오래된 전통과 경력의 주방장이 내는 회 맛이 일품인 곳이다.", "imageUrl": "/assets/mirror/067f6fe42c045a3a.jpg"}, {"name": "명월갈비", "category": "맛집", "searchQuery": "명월갈비", "distanceText": "351m", "imageUrl": "/assets/mirror/881d1f7ff820942f.jpg", "description": "군산 신창동에 있는 명월갈비는 군산 지역의 대표 양념 소갈비 전문 맛집이다. 플러스 등급의 한우 갈비를 비법 양념장으로 숙성한 단품 메뉴로 오랜 기간 동안 인기를 유지해오고 있다."}, {"name": "만남스넥", "category": "맛집", "searchQuery": "만남스넥", "distanceText": "405m", "imageUrl": "/assets/mirror/d2be5a053a944cfe.jpg", "description": "만남스넥은 군산 현지인들이 좋아하는 메뉴인 잡탕을 파는 분식집이다. 잡탕은 떡, 만두, 라면, 어묵 등을 넣고 같이 끓인 메뉴인데, 떡볶이나 라볶이보다 국물이 많고 부재료가 풍부해 잡탕으로 불리게…"}, {"name": "동국사다온", "category": "맛집", "searchQuery": "동국사다온", "distanceText": "418m", "imageUrl": "/assets/mirror/4db345bd2cb4f0d7.jpg", "description": "전라북도 군산시 일본식 사찰인 동국사 사찰 내에 있는 테이크아웃 커피 전문점이다. 불교 용품과 함께 대추차, 오미자차, 오룡차 등 다양한 차 종류를 판매하고 있다."}, {"name": "진갈비", "category": "맛집", "searchQuery": "진갈비", "distanceText": "426m", "description": "소박해보이지만 오랜 세월의 내공으로 묵직하고 깊은 떡갈비를 선사하는 맛집이다. 군산 현지인들이 추천하는 유명 맛집이기도 하다.", "imageUrl": "/assets/mirror/92cb907d07db9953.jpg"}, {"name": "한주옥", "category": "맛집", "searchQuery": "한주옥", "distanceText": "430m", "description": "‘한주옥’은 전북 군산시 영화동에 위치한 꽃게장 전문 음식점이다. 꽃게장 백반/정식, 대하장 백반/정식을 맛볼 수 있다.", "imageUrl": "/assets/mirror/f9cb7e88415edd06.jpg"}, {"name": "이성당", "category": "맛집", "searchQuery": "이성당", "distanceText": "455m", "imageUrl": "/assets/mirror/37c90494d28478e2.png", "description": "일본 시마네현 이즈모시에 살다가 1906년 조선으로 건너온 히로세 야스타로라는 일본인이 ‘이즈모야’라는 이름으로 문을 열어 영업한 것이 시초이다."}, {"name": "국제반점", "category": "맛집", "searchQuery": "국제반점", "distanceText": "467m", "imageUrl": "/assets/mirror/149c18951bdcaff8.jpg", "description": "군산 영화동에 있는 국제반점은 1960년대 초 진흥반점으로 개업해 현재는 상호를 국제 반점으로 변경하여 성업 중인 중식당이다. 영화 <타짜>에 등장해 군산의 관광 명소가 되었다."}, {"name": "사골뚝배기", "category": "맛집", "searchQuery": "사골뚝배기", "distanceText": "479m", "description": "사골 뚝배기는 전북 군산시에 위치한 한식 전문점이다. 한우고기와 한우사골만을 써서 아주 진하고 맛깔스러운 국물을 우려내어 요리를 만든다.", "imageUrl": "/assets/mirror/24043ae4f5854f96.jpg"}, {"name": "영화원", "category": "맛집", "searchQuery": "영화원", "distanceText": "495m", "imageUrl": "/assets/mirror/42e8d872198edaa9.jpg", "description": "군산 영화동에 있는 영화원은 1976년부터 지금까지 4대째 운영하는 오래된 중국 음식점이다. 대를 이어오면서 그 맛의 비결을 간직하고 있다."}, {"name": "스위트인디아", "category": "맛집", "searchQuery": "스위트인디아", "distanceText": "539m", "description": "전라북도 군산시에 위치한 스위트인디아는 인도 카레 맛집으로, 양고기, 소고기, 닭고기, 새우, 야채 총 5가지의 맛의 카레를 판매하고 있다.", "imageUrl": "/assets/mirror/bf6bb6183c6f6246.jpg"}, {"name": "틈(TEUM)", "category": "맛집", "searchQuery": "틈(TEUM)", "distanceText": "573m", "imageUrl": "/assets/mirror/81bd76fa51ae9ad1.jpg", "description": "군산항 근처에 있는 옛 미곡 창고를 그대로 활용한 카페다. ‘틈’이란 이름처럼 입구가 좁은 골목길 틈에 위치해 있어 간판을 눈여겨보지 않으면 찾기 어렵다."}, {"name": "미즈커피", "category": "맛집", "searchQuery": "미즈커피", "distanceText": "696m", "imageUrl": "/assets/mirror/3e5b3daae33ea51c.jpg", "description": "군산 근대문화 역사관 바로 옆에 자리한 카페로 일제강점기 무역회사였던 ‘미즈 상사’의 옛 사옥을 그대로 활용했다. 당시 일본인이 운영했던 미즈 상사는 식료품과 잡화 등을 수입해 판매하던 회사였다."}, {"name": "홍영장", "category": "맛집", "searchQuery": "홍영장", "distanceText": "725m", "description": "홍영장은 군산의 짬뽕거리에 위치한 중식당으로 방송에서 인정받은 맛집이다. 평소에 흔히 맛볼 수 없는 메뉴인 물짜장이 유명하다.", "imageUrl": "/assets/mirror/087900c82da3b2f5.jpg"}, {"name": "아리랑", "category": "맛집", "searchQuery": "아리랑", "distanceText": "732m", "description": "군산에 위치한 향토음식점으로 군산 지역의 특산물을 맛볼 수 있는 음식점이다.", "imageUrl": "/assets/mirror/1302460cb149975f.jpg"}, {"name": "운정식당", "category": "맛집", "searchQuery": "운정식당", "distanceText": "748m", "imageUrl": "/assets/mirror/00a8ac4ce017fb12.jpg", "description": "군산 개복동 골목의 터줏대감으로 40년 가까이 한 자리를 지키고 있는 식당이다."}, {"name": "빈해원", "category": "맛집", "searchQuery": "빈해원", "distanceText": "749m", "imageUrl": "/assets/mirror/483f52c2d2b7eebe.jpg", "description": "군산 빈해원은 1965년 콘크리트와 벽돌을 사용하여 지은 2층 건물이다. 이 건물은 1~2층이 개방된 내부공간과 각층에 여러 개의 방이 있는 독특한 구조이다."}, {"name": "국일식당", "category": "맛집", "searchQuery": "국일식당", "distanceText": "935m", "description": "전라북도 군산시에 위치한 국일식당은 콩나물의 아삭함과 매콤한 양념이 어우러진 아귀찜 전문점이다. 군산의 전통명가 답게 모범 착한업소로 선정된 곳이다.", "imageUrl": "/assets/mirror/6102b164464dbfa4.jpg"}, {"name": "명동소바", "category": "맛집", "searchQuery": "명동소바", "distanceText": "961m", "description": "‘군산명동소바’는 30년 이상된 군산 최초 원조 소바집이다. 메밀면발도 직접 연구 개발하고 매장 자체적으로 기계로 면을 빼서 쫄깃하고 탱탱한 맛으로 현지인뿐만 아니라 여행객의 많은 발길이 이어진다.", "imageUrl": "/assets/mirror/55f57f45fcebb460.jpg"}, {"name": "복성루", "category": "맛집", "searchQuery": "복성루", "distanceText": "1.3km", "imageUrl": "/assets/mirror/b3518ee8ef2b4aac.png", "description": "복성루는 빈해원 지린성과 함께 군산 3대 짬뽕 맛집으로 유명하며 싱싱한 오징어, 홍합, 꼬막 등 다양한 해산물과 야채를 넣고 우려낸 진한 짬뽕 국물이 일품인 가게로 줄 서서 맛보는 지역의 대표 짬뽕집으로 자리 잡고 있다."}, {"name": "유락식당", "category": "맛집", "searchQuery": "유락식당", "distanceText": "1.3km", "description": "김정례 대표가 1981년 문을 열어 40여 년 동안 지역을 대표하는 음식점으로 손님에게 최선의 맛과 서비스를 제공하고자 하는 곳이다.", "imageUrl": "/assets/mirror/5f92192c7b3e2cbf.jpg"}, {"name": "왕산반점", "category": "맛집", "searchQuery": "왕산반점", "distanceText": "1.5km", "imageUrl": "/assets/mirror/aebe437fd281f9ad.jpg", "description": "특허받은 콩나물 짬뽕으로 유명한 중화요리 집이다. 짬뽕면 위에 푸짐하게 올라간 콩나물, 홍합, 그 위에 신선한 낙지 한 마리까지 먹기 전부터 시각을 자극한다."}, {"name": "리투스카페", "category": "맛집", "searchQuery": "리투스카페", "distanceText": "1.6km", "imageUrl": "/assets/mirror/81438670a5485351.jpg", "description": "리투스카페는 군산 내항 부두 근처에 있는 오션 뷰 카페다. 특히 황금빛 일몰이 아름다워 일몰 카페로도 유명하다."}, {"name": "일풍식당", "category": "맛집", "searchQuery": "일풍식당", "distanceText": "1.6km", "description": "‘일풍식당’은 각종 TV매체에서 맛집으로 다룬 곳이다. 골목 사이에 위치해 있어 찾을 때 약간 주의를 해야 한다.", "imageUrl": "/assets/mirror/e13c8a2a1aa2762c.jpg"}, {"name": "수송반점", "category": "맛집", "searchQuery": "수송반점", "distanceText": "1.7km", "imageUrl": "/assets/mirror/98539e6361e317b1.jpg", "description": "수송 반점은 군산시 서흥남동, 서흥 중학교 근처에 있는 중식당이다."}, {"name": "군산활어회센타", "category": "맛집", "searchQuery": "군산활어회센타", "distanceText": "1.9km", "imageUrl": "/assets/mirror/93146c8ae53252ff.jpg", "description": "군산 문화동에 있는 '군산 활어회센터'는 가까이 있는 군산항 활어회 직판장에서 가져오는 활어회로 신선도와 식감 면에서 뛰어나다."}, {"name": "진성원", "category": "맛집", "searchQuery": "진성원", "distanceText": "1.9km", "imageUrl": "/assets/mirror/83766fa52745ef37.jpg", "description": "진성원은 전라북도 군산시 경암동에 있는 중식당이다."}, {"name": "서우식당", "category": "맛집", "searchQuery": "서우식당", "distanceText": "2.0km", "imageUrl": "/assets/mirror/de286714af7faaba.jpg", "description": "서우식당은 저렴한 가격에 푸짐한 한상차림으로 허영만의 백반 기행 등 TV 프로그램에도 소개된 한식집이다."}], "festivals": [{"name": "2026 군산 은파벚꽃야시장", "month": "3~4월", "period": "2026년 3월 28일 – 4월 12일", "location": "은파호수공원", "description": "군산 은파벚꽃야시장은 상인분들의 한 해를 힘차게 시작할 수 있도록 돕는 자리이자 군산의 대표적인 봄 축제였던 벚꽃축제가 사라진 아쉬움을 해소하고자 기획하게 된 행사이다.", "searchQuery": "2026 군산 은파벚꽃야시장", "season": "봄", "startDate": "2026-03-28", "endDate": "2026-04-12", "imageUrl": "/assets/mirror/82606e203454e3ff.jpg"}, {"name": "군산 수제맥주&블루스 페스티벌", "month": "6월", "period": "2026년 6월 12일 – 14일", "location": "군산근대역사박물관 주차장", "description": "'군산 수제맥주&블루스 페스티벌'은 국내 유일의 로컬 수제맥주 축제로, 100% 군산보리로 만드는 군산맥아로 만든 진짜 우리 수제맥주와 블루스음악과 함께 항구도시 군산의 초여름 밤, 낭만을 만끽할…", "searchQuery": "군산 수제맥주&블루스 페스티벌", "season": "여름", "startDate": "2026-06-12", "endDate": "2026-06-14", "imageUrl": "/assets/mirror/92f65116f3e58ab6.jpg"}, {"name": "군산짬뽕페스티벌", "month": "10월", "period": "2025년 10월 9일 – 12일", "location": "군산 백년광장 일원(장미동)", "description": "70년 짬뽕역사의 도시, 군산에서 10월 9일부터 12일까지, 4일간 짬뽕페스티벌이 개최된다. 얼큰, 화끈, 개운한 국물 베이스인 해물가득한 짬뽕, 고기짬뽕 등 다양한 테마의 짬뽕을 축제장에서 선보…", "searchQuery": "군산짬뽕페스티벌", "season": "가을", "startDate": "2025-10-09", "endDate": "2025-10-12", "imageUrl": "/assets/mirror/a1b00af88aa06cd9.jpg"}, {"name": "군산시간여행축제", "month": "10월", "period": "2026년 10월 2일 – 5일", "location": "구·시청광장 및 시간여행마을 일원", "description": "군산시간여행축제는 일제강점기 민중의 항거와 치열한 삶의 역사를 간직한 근대 군산을 배경으로, 군산의 과거와 현재, 그리고 미래를 잇는 특별한 시간여행을 선사하는 축제이다.", "searchQuery": "군산시간여행축제", "season": "가을", "startDate": "2026-10-02", "endDate": "2026-10-05", "imageUrl": "/assets/mirror/4b4086345c20b968.jpg"}, {"name": "익산천만송이 국화축제", "month": "10월", "season": "가을", "location": "전북특별자치도 익산시 하나로 322 (어양동) · 차로 30분", "description": "가을을 대표하는 축제로, 대형 조형물에 국화를 심어 만든 작품과 전국 최고 규모의 국화정원, 국화분재 특별전시가 열린다.", "searchQuery": "익산천만송이 국화축제", "imageUrl": "/assets/mirror/6194651114d1e740.jpg"}, {"name": "익산마한문화대전", "month": "10월", "season": "가을", "location": "전북특별자치도 익산시 금마면 고도9길 41-14 · 차로 35분", "description": "익산 마한의 정통성을 다지고 익산을 마한 문화 선진지로 알리기 위해 여는 행사이다.", "searchQuery": "익산마한문화대전", "imageUrl": "/assets/mirror/49558512d8d52e1a.jpg"}, {"name": "강경젓갈축제", "month": "10월", "season": "가을", "location": "충청남도 논산시 강경읍 금백로 45 · 차로 40분", "description": "전통의 맛과 문화를 현대적으로 재해석한 축제로, 금강의 자연경관과 강경의 역사 자원을 함께 둘러볼 수 있다.", "searchQuery": "강경젓갈축제", "imageUrl": "/assets/mirror/7ccd68de6b280bc2.jpg"}, {"name": "부안 설(雪)숭어 축제", "month": "12월", "season": "겨울", "location": "전북특별자치도 부안군 부안읍 서외리 · 차로 35분", "description": "겨울철에 맛이 오르는 설숭어를 주제로 한 지역 특산 수산물 축제이다. 설숭어 음식 판매와 시식, 어촌 문화 체험 프로그램이 열린다.", "searchQuery": "부안 설숭어 축제", "imageUrl": "/assets/mirror/4389ded2dee1ad98.jpg"}, {"name": "서천 마량진항 해넘이 해돋이 행사", "month": "12월", "season": "겨울", "location": "충청남도 서천군 서면 서인로 58 · 차로 30분", "description": "12월 31일부터 1월 1일까지 마량진항 일원에서 열린다. 서해안에서 일몰과 일출을 한자리에서 볼 수 있는 곳이다.", "searchQuery": "서천 마량진항 해넘이 해돋이", "imageUrl": "/assets/mirror/8fc0a7522e3eb080.png"}, {"name": "선유도 여름 노을축제", "season": "여름", "month": "7월", "period": "2026년 7월 25일", "location": "군산 선유도해수욕장", "description": "서해 낙조 명소인 선유도해수욕장 특설무대에서 열리는 한여름 밤 축제입니다. 지역 예술인의 공연과 참여 행사가 열리고 폭죽쇼로 마무리합니다. 사진은 개최 장소인 선유도해수욕장입니다.", "searchQuery": "선유도 여름 노을축제", "imageUrl": "/assets/mirror/c971fb408ad897e7.jpg"}, {"name": "군산 국가유산 야행", "month": "8월", "season": "여름", "location": "구 조선식량영단 등 원도심 국가유산 일원 · 걸어서 20분", "description": "원도심의 국가유산을 밤에 여는 행사다. 야경·야로·야사 등 여덟 갈래로 나뉘어 신흥동 일본식 가옥의 정원 조명, 스탬프 투어, 옛 건물에서 여는 공연과 야시장이 저녁부터 밤까지 이어진다.", "searchQuery": "군산 국가유산 야행"}], "weather": {"temperature": 27.9, "condition": "구름많음", "observedAt": "2026-09-03T12:30", "notes": {"맑음": "마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.", "흐림": "빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.", "비": "백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.", "눈": "기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다."}, "tempNotes": {"혹서": "낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.", "더움": "선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.", "선선": "걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.", "쌀쌀": "월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.", "추움": "밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다."}, "noteSets": {"맑음": ["마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.", "기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다.", "볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다.", "창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다.", "해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다."], "구름많음": ["빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.", "그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다.", "해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다.", "구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다.", "빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다."], "그외": ["해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다.", "하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다.", "안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다.", "소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다.", "바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다."], "비": ["백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.", "비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다.", "마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요.", "실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다.", "우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다."], "눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다.", "눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다.", "눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다.", "디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요.", "창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다."]}, "tempNoteSets": {"혹서": ["낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.", "한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요.", "옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다.", "경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다.", "물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다."], "더움": ["선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.", "낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다.", "우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다.", "해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다.", "가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요."], "선선": ["걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.", "원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다.", "차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다.", "해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다.", "낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다."], "쌀쌀": ["월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.", "겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다.", "실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다.", "따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요.", "오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요."], "추움": ["밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다.", "항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요.", "장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다.", "따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다.", "일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다."]}}, "syncedAt": "2026-09-14T07:42:00+00:00"}, "routes": [], "narrative": {"about": ["스테이,머뭄은 전북특별자치도 군산시 신흥동 절골길에 있는 독채 펜션입니다. 1920년대에 지어진 백 년 고택으로, 국가등록문화유산인 군산 신흥동 일본식 가옥(히로쓰 가옥) 후문 바로 옆 — 걸어서 13m 거리입니다. 사장님은 이 집이 히로쓰 가옥 관리인이 살던 집이라고 전합니다. 2024년 5월에 리모델링을 마치고 문을 열었고, A동과 B동 두 동을 각각 한 팀만 씁니다. 기준 2인 · 최대 4인까지 묵을 수 있습니다.", "A동은 편안한 일본집에 와 있는 듯한 느낌의 공간이고, B동은 아늑하고 깔끔한 현대식 모던 스타일 공간입니다. 두 동 모두 독립된 아담한 정원과 창고형 CAFÉ 공간이 딸려 있어, 마당과 거실에서 새소리를 들으며 쉴 수 있습니다.", "인테리어와 가구, 소품, 침구를 하나하나 준비해 두었습니다. 특히 침구류는 매일 세탁·살균하고 건조해 쓰시는 분이 바뀔 때마다 새것처럼 나갑니다."], "summary": "1920년대 적산가옥을 고쳐 지은 독채 두 동, 매일 세탁·살균하는 침구", "tagline": "히로쓰 가옥 담 너머, 백 년 된 집 한 채", "catchphrases": {"version": 1, "items": [{"text": "백 년 된 집에서 하룻밤", "kind": "general"}, {"text": "담 너머는 히로쓰 가옥입니다", "kind": "general"}, {"text": "독채 두 동, 마당은 각자 씁니다", "kind": "general"}, {"text": "기와 아래에서 아침을 맞습니다", "kind": "general"}, {"text": "새소리로 눈이 떠지는 집", "kind": "general"}, {"text": "골목이 조용해지는 시간에 도착하세요", "kind": "general"}, {"text": "침구는 매일 새것처럼 나갑니다", "kind": "general"}, {"text": "마당 평상에 앉으면 항구 소리가 들립니다", "kind": "general"}, {"text": "1920년대에 지은 집, 지금 쓰는 집", "kind": "general"}, {"text": "대문을 닫으면 골목이 멀어집니다", "kind": "general"}, {"text": "창고를 고친 카페 공간이 딸려 있습니다", "kind": "general"}, {"text": "밤에는 정원 석등만 켜 둡니다", "kind": "general"}, {"text": "초원사진관까지 걸어서 3분", "kind": "general"}, {"text": "원도심 한가운데, 차는 세워 두세요", "kind": "general"}, {"text": "A동은 일본식, B동은 모던입니다", "kind": "general"}, {"text": "네 사람까지 묶는 독채입니다", "kind": "general"}, {"text": "오후 세 시부터 이 집은 손님 것입니다", "kind": "general"}, {"text": "이 골목에서는 서두를 일이 없습니다", "kind": "general"}, {"text": "백 년 된 기와가 비를 받습니다", "kind": "general"}, {"text": "여행이라기보다 하루의 이사입니다", "kind": "general"}, {"text": "군산에서 가장 조용한 담 안쪽", "kind": "general"}, {"text": "이 집의 시간은 조금 느립니다", "kind": "general"}, {"text": "마당, 정원, 그리고 평상 하나", "kind": "general"}, {"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"}, {"text": "창을 열면 옆집 기와가 보입니다", "kind": "general"}, {"text": "담배 냄새가 없는 집입니다", "kind": "general"}, {"text": "이불에서 볕 냄새가 납니다", "kind": "general"}, {"text": "두 동 모두 남의 인기척이 없습니다", "kind": "general"}, {"text": "내항까지 걸어 십 분", "kind": "general"}, {"text": "오래된 것을 고쳐 쓰는 집", "kind": "general"}, {"text": "부엌이 있어 아침은 지어 드셔도 됩니다", "kind": "general"}, {"text": "밤에는 골목 등만 남습니다", "kind": "general"}, {"text": "백 년 된 집이 아직 손님을 받습니다", "kind": "general"}, {"text": "개항장 골목의 끝집입니다", "kind": "general"}, {"text": "문패보다 기와를 보고 찾아오세요", "kind": "general"}, {"text": "걸어서 도는 여행에 알맞은 자리", "kind": "general"}, {"text": "마루에 앉는 시간이 일정의 절반입니다", "kind": "general"}, {"text": "좁은 골목 끝, 대문 하나", "kind": "general"}, {"text": "여기서는 계획을 반쯤 접어도 됩니다", "kind": "general"}, {"text": "하루를 통째로 쓰는 숙소", "kind": "general"}, {"text": "정원에 꽃이 먼저 옵니다", "kind": "season", "season": "봄"}, {"text": "봄에는 대문을 열어 둡니다", "kind": "season", "season": "봄"}, {"text": "벚꽃 지는 골목을 걸어 오세요", "kind": "season", "season": "봄"}, {"text": "마당에 앉는 계절이 시작됩니다", "kind": "season", "season": "봄"}, {"text": "새소리가 가장 많은 철입니다", "kind": "season", "season": "봄"}, {"text": "창을 열면 봄이 방까지 들어옵니다", "kind": "season", "season": "봄"}, {"text": "처마 그늘이 가장 긴 계절입니다", "kind": "season", "season": "여름"}, {"text": "마당에 물을 뿌리면 시원해집니다", "kind": "season", "season": "여름"}, {"text": "여름 밤에는 평상이 제일 좋습니다", "kind": "season", "season": "여름"}, {"text": "창고형 카페가 낮에는 서늘합니다", "kind": "season", "season": "여름"}, {"text": "소나기는 기와가 받아 줍니다", "kind": "season", "season": "여름"}, {"text": "해가 길어 저녁이 넉넉합니다", "kind": "season", "season": "여름"}, {"text": "기와 위로 가을 볕이 마릅니다", "kind": "season", "season": "가을"}, {"text": "가을 바람이 담을 넘어옵니다", "kind": "season", "season": "가을"}, {"text": "가을 골목은 해가 빨리 눕습니다", "kind": "season", "season": "가을"}, {"text": "창을 열어 두고 자도 되는 밤입니다", "kind": "season", "season": "가을"}, {"text": "가을에는 평상이 가장 좋은 자리입니다", "kind": "season", "season": "가을"}, {"text": "마당에 잎이 떨어지는 계절입니다", "kind": "season", "season": "가을"}, {"text": "기와에 눈이 앉으면 골목이 조용해집니다", "kind": "season", "season": "겨울"}, {"text": "겨울 아침 마당은 서리 밭입니다", "kind": "season", "season": "겨울"}, {"text": "이불 속에서 겨울 바람 소리를 듣습니다", "kind": "season", "season": "겨울"}, {"text": "눈 오면 정원 석등만 켜 둡니다", "kind": "season", "season": "겨울"}, {"text": "항구 바람이 매운 계절입니다", "kind": "season", "season": "겨울"}, {"text": "겨울에는 카페 공간이 가장 아늑합니다", "kind": "season", "season": "겨울"}, {"text": "새해 첫 아침을 백 년 집에서", "kind": "month", "month": 1}, {"text": "겨울 골목이 가장 조용한 달입니다", "kind": "month", "month": 1}, {"text": "아직 바람이 찬 원도심입니다", "kind": "month", "month": 2}, {"text": "겨울 끝을 마당에서 봅니다", "kind": "month", "month": 2}, {"text": "정원에 첫 꽃이 오는 달입니다", "kind": "month", "month": 3}, {"text": "대문을 열어 두기 시작합니다", "kind": "month", "month": 3}, {"text": "벚꽃 지는 골목을 걷는 달입니다", "kind": "month", "month": 4}, {"text": "마당에 앉는 시간이 길어집니다", "kind": "month", "month": 4}, {"text": "새소리가 가장 많은 달입니다", "kind": "month", "month": 5}, {"text": "창을 열어 두고 자는 밤입니다", "kind": "month", "month": 5}, {"text": "처마 그늘이 길어지는 달입니다", "kind": "month", "month": 6}, {"text": "여름 앞의 원도심은 한가합니다", "kind": "month", "month": 6}, {"text": "소나기를 기와가 받는 달입니다", "kind": "month", "month": 7}, {"text": "여름 밤 평상이 제일 좋습니다", "kind": "month", "month": 7}, {"text": "낮에는 카페 공간이 서늘합니다", "kind": "month", "month": 8}, {"text": "해가 길어 저녁이 넉넉한 달입니다", "kind": "month", "month": 8}, {"text": "볕이 마르기 시작하는 달입니다", "kind": "month", "month": 9}, {"text": "가을이 담을 넘어오는 구월입니다", "kind": "month", "month": 9}, {"text": "잎이 마당에 떨어지는 달입니다", "kind": "month", "month": 10}, {"text": "걷기에 가장 좋은 달입니다", "kind": "month", "month": 10}, {"text": "해가 빨리 눕는 골목입니다", "kind": "month", "month": 11}, {"text": "이불이 가장 따뜻한 달입니다", "kind": "month", "month": 11}, {"text": "기와에 눈이 앉는 달입니다", "kind": "month", "month": 12}, {"text": "한 해의 끝을 조용히 보내는 자리", "kind": "month", "month": 12}, {"text": "마당 평상에 앉으면 새소리만 들립니다", "kind": "weather", "weather": "맑음"}, {"text": "정원 쪽으로 그늘이 길게 눕는 시간입니다", "kind": "weather", "weather": "맑음"}, {"text": "기와가 마르는 냄새가 납니다", "kind": "weather", "weather": "맑음"}, {"text": "빛이 부드러운 날입니다", "kind": "weather", "weather": "구름많음"}, {"text": "골목 사진이 제일 잘 나오는 빛입니다", "kind": "weather", "weather": "구름많음"}, {"text": "빛이 낮게 깔리는 날입니다", "kind": "weather", "weather": "흐림"}, {"text": "이런 날은 카페 공간이 가장 아늑합니다", "kind": "weather", "weather": "흐림"}, {"text": "백 년 된 기와를 타고 내리는 소리가 다릅니다", "kind": "weather", "weather": "비"}, {"text": "카페 공간에서 빗소리를 들으실 수 있습니다", "kind": "weather", "weather": "비"}, {"text": "비 오는 골목에는 사람이 없습니다", "kind": "weather", "weather": "비"}, {"text": "기와에 눈이 앉으면 골목이 통째로 조용해집니다", "kind": "weather", "weather": "눈"}, {"text": "눈 밟는 소리가 담 안에서 크게 들립니다", "kind": "weather", "weather": "눈"}]}}, "theme": {"templateId": "stay-retro", "colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0", "card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"}, "fontStyle": "옛 간판체", "sections": [{"id": "hero", "name": "히어로", "enabled": true, "locked": true}, {"id": "intro", "name": "소개", "enabled": true, "locked": false}, {"id": "rooms", "name": "객실 안내", "enabled": true, "locked": false}, {"id": "event", "name": "스테이,머뭄 소식", "enabled": true, "locked": false, "data": "{\"kind\":\"event\",\"version\":1,\"title\":\"스테이,머뭄 소식\",\"subtitle\":\"미리 알아 두시면 좋은 것들\",\"linkUrl\":\"https://www.instagram.com/staymeomoom\",\"linkLabel\":\"인스타그램에서 보기\",\"items\":[{\"kind\":\"공지\",\"title\":\"입실 전 확인해 주세요\",\"summary\":\"머무시기 전에 꼭 읽어 주셔야 하는 것들입니다. 미숙지로 생긴 불편과 손해는 도와드리기 어렵습니다.\",\"body\":\"· 반려동물은 입실하실 수 없습니다.\\n· 건물과 객실 안은 절대 금연입니다. 바로 옆이 군산 신흥동 일본식가옥(히로쓰 가옥)이라 화재에 각별히 유의해 주세요.\\n· 냄새가 잘 빠지지 않는 생선구이·고기구이·튀김류 조리는 하실 수 없습니다.\\n· 객실 안에서 음식을 드실 때 오염에 주의해 주세요.\\n· 객실 시설과 비품, 침구가 손상·파손·오염되면 변상을 요청드릴 수 있습니다.\\n· 정기적으로 방역하고 있으나 자연과 함께하는 공간이라 가끔 벌레나 이물질이 들어올 수 있습니다. 이 사유로는 환불이 어려운 점 양해 부탁드립니다.\\n· 귀중품과 현금은 분실 위험이 있으니 직접 관리해 주세요.\\n· 안전과 보안을 위해 대문 입구 쪽에 CCTV를 운영하고 있습니다. 객실 안에는 없습니다.\\n· 숙박업소는 법적으로 청소년 혼숙이 금지되어 있습니다. 미성년자는 보호자를 동반하셔야 이용하실 수 있습니다.\",\"verified\":\"확인\"},{\"kind\":\"공지\",\"title\":\"인원이 늘면 미리 연락 주세요\",\"summary\":\"예약하신 인원보다 늘어나는 경우, 오시기 전에 숙소로 연락 부탁드립니다.\",\"body\":\"· 기준 인원(2인)을 넘으면 1인당 20,000원이 추가됩니다.\\n· 최대 인원(4인)을 넘으면 입실이 불가능할 수 있고, 이 사유로는 환불을 받으실 수 없습니다.\\n· 다음 이용 고객을 위해 입실 15:00 이후, 퇴실 11:00 이전을 지켜 주세요.\",\"howTo\":\"연락처 0507-1497-0983\",\"verified\":\"확인\"},{\"kind\":\"공지\",\"title\":\"직화·개인 취사도구는 반입이 안 됩니다\",\"summary\":\"백 년 된 목조 고택이라 화재 예방 규정이 엄격합니다.\",\"body\":\"· 객실 안에서 생선이나 고기 등을 굽는 직화 방식은 허용되지 않습니다.\\n· 그릴, 숯, 전기·전열기구 등 개인적으로 준비해 오시는 취사도구는 반입이 금지되어 있습니다.\\n· 주방에서 간단한 조리는 하실 수 있습니다.\\n· 흡연은 지정된 장소를 이용해 주시고, 다른 이용객에게 피해가 갈 수 있는 오락·음주·고성방가는 삼가 주세요.\",\"verified\":\"확인\"}]}"}, {"id": "info", "name": "기본 정보", "enabled": true, "locked": true}, {"id": "booking", "name": "실시간 예약", "enabled": false, "locked": false}, {"id": "video", "name": "ADO2 영상 보기", "enabled": true, "locked": false, "data": "{\"kind\": \"video\", \"version\": 1, \"title\": \"ADO2 영상 보기\", \"subtitle\": \"ADO2로 만든 홍보 영상입니다.\", \"items\": [{\"url\": \"https://youtube.com/shorts/c2ZdwhaB7S4\", \"caption\": \"머뭄의 하루\", \"verified\": \"확인\"}, {\"url\": \"https://youtube.com/shorts/_bbo3dVgOXs\", \"caption\": \"마당과 창고형 카페 공간\", \"verified\": \"확인\"}, {\"url\": \"https://youtube.com/shorts/qrCG4rOeJvs\", \"caption\": \"밤의 머뭄\", \"verified\": \"확인\"}], \"linkUrl\": \"https://ado2.o2osolution.ai/\", \"linkLabel\": \"ADO2 알아보기\"}"}, {"id": "photos", "name": "사진 갤러리", "enabled": true, "locked": false, "variantId": "photos.carousel"}, {"id": "map", "name": "오시는 길", "enabled": true, "locked": true}, {"id": "festival", "name": "계절별 축제", "enabled": true, "locked": false}, {"id": "local", "name": "지역 정보", "enabled": true, "locked": false}, {"id": "itinerary", "name": "추천 일정", "enabled": true, "locked": false, "data": "{\"kind\": \"itinerary\", \"version\": 1, \"title\": \"추천 일정\", \"subtitle\": \"머뭄에서 나서고 머뭄으로 돌아옵니다 — 떠나는 날만 그대로 길을 나섭니다\", \"items\": [{\"name\": \"섬까지 다녀오는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"차를 가져온 손님 · 차량 필요\", \"why\": \"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 45, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 8, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 75, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 150, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"늦은 점심 · 국제반점\", \"minutes\": 55, \"moveMinutes\": 85, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}]}]}, {\"name\": \"걸어서만 도는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"차 없이 오는 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 49, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 52, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 42, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 79, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 47, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 52, \"moveMinutes\": 4, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 26, \"moveMinutes\": 4, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"비가 와도 되는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"비 예보를 보고 오는 손님 · 차량 필요\", \"why\": \"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 39, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 88, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 44, \"moveMinutes\": 3, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 68, \"moveMinutes\": 5, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 39, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 29, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 80, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 52, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 57, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 째보선창 근처 카페\", \"minutes\": 69, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다.\", \"searchQuery\": \"군산 째보선창 카페\"}]}]}, {\"name\": \"근대건축만 보는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"건물을 보러 오는 손님 · 차량 필요\", \"why\": \"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 28, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 61, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 71, \"moveMinutes\": 6, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 73, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 군산근대미술관\", \"minutes\": 70, \"moveMinutes\": 9, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 48, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 19, \"moveMinutes\": 12, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}]}]}, {\"name\": \"이성당으로 시작하는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"빵을 좋아하는 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 27, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 22, \"moveMinutes\": 6, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 72, \"moveMinutes\": 6, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 36, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 50, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 36, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 81, \"moveMinutes\": 12, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"철길마을까지 가는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"사진을 찍는 손님 · 차량 필요\", \"why\": \"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:40\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 28, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 33, \"moveMinutes\": 8, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 52, \"moveMinutes\": 10, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 9, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 77, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 80, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 48, \"moveMinutes\": 11, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 57, \"moveMinutes\": 6, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}]}]}, {\"name\": \"항구만 도는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"바다가 보고 싶은 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:40\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 37, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 21, \"moveMinutes\": 17, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 74, \"moveMinutes\": 13, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 42, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 79, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 째보선창\", \"minutes\": 32, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 58, \"moveMinutes\": 12, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 32, \"moveMinutes\": 7, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 42, \"moveMinutes\": 13, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"소설 『탁류』를 따라 걷는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"채만식을 읽어 본 손님 · 차량 필요\", \"why\": \"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 33, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"저녁 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 11, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:00\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 82, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 채만식문학관\", \"minutes\": 66, \"moveMinutes\": 18, \"latitude\": 36.0085981, \"longitude\": 126.7578853, \"note\": \"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다.\", \"searchQuery\": \"군산 채만식문학관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 17, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 33, \"moveMinutes\": 12, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}]}]}, {\"name\": \"아이와 함께 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"아이를 데려온 손님 · 차량 필요\", \"why\": \"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 26, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 21, \"moveMinutes\": 6, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 만남스넥\", \"minutes\": 42, \"moveMinutes\": 4, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 34, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 26, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 42, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 이성당\", \"minutes\": 30, \"moveMinutes\": 11, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"오후 · 월명호수\", \"minutes\": 42, \"moveMinutes\": 10, \"latitude\": 35.9766391, \"longitude\": 126.6913696, \"note\": \"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다.\", \"searchQuery\": \"군산 월명호수\"}]}]}, {\"name\": \"늦게 도착하는 1박 2일\", \"duration\": \"1박 2일\", \"audience\": \"퇴근하고 오는 손님 · 차량 필요\", \"why\": \"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"18:20\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 75, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 75, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 50, \"moveMinutes\": 6, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 20, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}]}]}, {\"name\": \"천천히 도는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"쉬러 오는 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:20\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 35, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 40, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 92, \"moveMinutes\": 7, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 46, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 35, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 223, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 53, \"moveMinutes\": 5, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 173, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 74, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 70, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 44, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 50, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 75, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"섬과 도심을 반씩 도는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차를 가져온 손님 · 차량 필요\", \"why\": \"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:10\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 구 군산세관 본관\", \"minutes\": 30, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 3, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"07:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 60, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 150, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"오후 · 장자도\", \"minutes\": 50, \"moveMinutes\": 20, \"latitude\": 35.8101, \"longitude\": 126.3973, \"note\": \"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다.\", \"searchQuery\": \"군산 장자도\"}, {\"name\": \"늦은 점심 · 국제반점\", \"minutes\": 55, \"moveMinutes\": 88, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 168, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 75, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 77, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 35, \"moveMinutes\": 4, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}]}]}, {\"name\": \"골목을 세 번 나눠 걷는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차 없이 오는 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 49, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 66, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 99, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 6, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 군산근대건축관\", \"minutes\": 50, \"moveMinutes\": 4, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 22, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 164, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 82, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 우체통거리\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 44, \"moveMinutes\": 4, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 군산 시간여행마을\", \"minutes\": 66, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"먹으러 오는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"맛집을 도는 손님 · 차량 필요\", \"why\": \"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:30\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 46, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 물밀소\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9845243, \"longitude\": 126.7073211, \"note\": \"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다.\", \"searchQuery\": \"군산 물밀소\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 34, \"moveMinutes\": 6, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 86, \"moveMinutes\": 3, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 46, \"moveMinutes\": 11, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:20\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 69, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산 시간여행마을\", \"minutes\": 69, \"moveMinutes\": 7, \"latitude\": 35.9883192, \"longitude\": 126.7109377, \"note\": \"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다.\", \"searchQuery\": \"군산 시간여행마을\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 57, \"moveMinutes\": 6, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 29, \"moveMinutes\": 3, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 127, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 80, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 34, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 104, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 동국사\", \"minutes\": 50, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 63, \"moveMinutes\": 10, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 103, \"moveMinutes\": 5, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}]}]}, {\"name\": \"아무 데도 안 가는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"쉬려고만 오는 손님 · 차량 필요\", \"why\": \"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 45, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 96, \"moveMinutes\": 3, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 48, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 232, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 44, \"moveMinutes\": 6, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 187, \"moveMinutes\": 6, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 5, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:00\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 98, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 동국사\", \"minutes\": 46, \"moveMinutes\": 6, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"점심 · 물밀소\", \"minutes\": 46, \"moveMinutes\": 3, \"latitude\": 35.9845243, \"longitude\": 126.7073211, \"note\": \"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다.\", \"searchQuery\": \"군산 물밀소\"}, {\"name\": \"오후 · 월명호수\", \"minutes\": 65, \"moveMinutes\": 9, \"latitude\": 35.9766391, \"longitude\": 126.6913696, \"note\": \"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다.\", \"searchQuery\": \"군산 월명호수\"}]}]}, {\"name\": \"사진만 찍는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"사진을 찍는 손님 · 차량 필요\", \"why\": \"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:50\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 32, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 24, \"moveMinutes\": 4, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"오후 · 동국사\", \"minutes\": 28, \"moveMinutes\": 8, \"latitude\": 35.9827663, \"longitude\": 126.7074296, \"note\": \"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다.\", \"searchQuery\": \"군산 동국사\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 64, \"moveMinutes\": 7, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 32, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 24, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 48, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 경암동 철길마을\", \"minutes\": 40, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 43, \"moveMinutes\": 11, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 45, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 21, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 190, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 45, \"moveMinutes\": 7, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 24, \"moveMinutes\": 7, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:50\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 108, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 우체통거리\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 36, \"moveMinutes\": 8, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}]}]}, {\"name\": \"비가 와도 되는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"비 예보를 보고 오는 손님 · 차량 필요\", \"why\": \"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 45, \"moveMinutes\": 3, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 5, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 72, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 54, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 60, \"moveMinutes\": 8, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 째보선창 근처 카페\", \"minutes\": 72, \"moveMinutes\": 15, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다.\", \"searchQuery\": \"군산 째보선창 카페\"}, {\"name\": \"오후 · 동국사다온\", \"minutes\": 48, \"moveMinutes\": 8, \"latitude\": 35.9860907, \"longitude\": 126.71444, \"note\": \"대추차나 오미자차를 시켜 절 마당을 보며 마십니다.\", \"searchQuery\": \"군산 동국사 다온\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 84, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 90, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 80, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 채만식문학관\", \"minutes\": 72, \"moveMinutes\": 18, \"latitude\": 36.0085981, \"longitude\": 126.7578853, \"note\": \"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다.\", \"searchQuery\": \"군산 채만식문학관\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 60, \"moveMinutes\": 17, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 이성당\", \"minutes\": 42, \"moveMinutes\": 3, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}]}]}, {\"name\": \"늦게 시작하는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"아침이 힘든 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 히로쓰 가옥\", \"minutes\": 30, \"moveMinutes\": 3, \"latitude\": 35.9861696, \"longitude\": 126.7060575, \"note\": \"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다.\", \"searchQuery\": \"군산 신흥동 일본식가옥\"}, {\"name\": \"오후 · 우체통거리\", \"minutes\": 25, \"moveMinutes\": 8, \"latitude\": 35.9859965, \"longitude\": 126.7119708, \"note\": \"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다.\", \"searchQuery\": \"군산 우체통거리\"}, {\"name\": \"저녁 · 도란\", \"minutes\": 80, \"moveMinutes\": 6, \"latitude\": 35.986822, \"longitude\": 126.70751, \"note\": \"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다.\", \"searchQuery\": \"군산 도란\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 40, \"moveMinutes\": 14, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"10:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 60, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 50, \"moveMinutes\": 5, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대역사박물관\", \"minutes\": 90, \"moveMinutes\": 10, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 20, \"moveMinutes\": 10, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 156, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 70, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 30, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"10:10\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 50, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 이성당\", \"minutes\": 35, \"moveMinutes\": 7, \"latitude\": 35.9870443, \"longitude\": 126.7111681, \"note\": \"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다.\", \"searchQuery\": \"군산 이성당\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 40, \"moveMinutes\": 5, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 30, \"moveMinutes\": 6, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 40, \"moveMinutes\": 4, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"섬에서 하루를 다 쓰는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"차를 가져온 손님 · 차량 필요\", \"why\": \"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 38, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 36, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 17, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 66, \"moveMinutes\": 13, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"07:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 57, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 고군산군도 선유도\", \"minutes\": 142, \"moveMinutes\": 84, \"latitude\": 35.810205, \"longitude\": 126.4124053, \"note\": \"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다.\", \"searchQuery\": \"군산 선유도\"}, {\"name\": \"오후 · 장자도\", \"minutes\": 48, \"moveMinutes\": 20, \"latitude\": 35.8101, \"longitude\": 126.3973, \"note\": \"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다.\", \"searchQuery\": \"군산 장자도\"}, {\"name\": \"늦은 점심 · 한일옥\", \"minutes\": 48, \"moveMinutes\": 88, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 170, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 71, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 28, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 71, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 군산근대건축관\", \"minutes\": 43, \"moveMinutes\": 10, \"latitude\": 35.9894202, \"longitude\": 126.7121232, \"note\": \"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다.\", \"searchQuery\": \"군산근대건축관\"}, {\"name\": \"점심 · 만남스넥\", \"minutes\": 38, \"moveMinutes\": 9, \"latitude\": 35.9841788, \"longitude\": 126.7097763, \"note\": \"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다.\", \"searchQuery\": \"군산 만남스넥\"}, {\"name\": \"오후 · 경암동 철길마을\", \"minutes\": 48, \"moveMinutes\": 11, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}]}]}, {\"name\": \"소설 『탁류』를 따라 걷는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"채만식을 읽어 본 손님 · 도보로 충분 (차 없이 가능)\", \"why\": \"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"15:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 33, \"moveMinutes\": 19, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 군산 내항 부잔교\", \"minutes\": 38, \"moveMinutes\": 5, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"저녁 · 일흥옥\", \"minutes\": 55, \"moveMinutes\": 13, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 44, \"moveMinutes\": 13, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"머뭄에서 쉬는 날 · 스테이 머뭄\", \"minutes\": 231, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다.\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 55, \"moveMinutes\": 5, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 173, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 빈해원\", \"minutes\": 77, \"moveMinutes\": 11, \"latitude\": 35.9888841, \"longitude\": 126.7139765, \"note\": \"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다.\", \"searchQuery\": \"군산 빈해원\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 33, \"moveMinutes\": 11, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"08:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 82, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요.\"}, {\"name\": \"오전 · 군산근대역사박물관\", \"minutes\": 99, \"moveMinutes\": 11, \"latitude\": 35.9908197, \"longitude\": 126.7121232, \"note\": \"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다.\", \"searchQuery\": \"군산근대역사박물관\"}, {\"name\": \"점심 · 국제반점\", \"minutes\": 61, \"moveMinutes\": 5, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 33, \"moveMinutes\": 3, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"마무리 · 스테이 머뭄\", \"minutes\": 44, \"moveMinutes\": 4, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다.\"}]}]}, {\"name\": \"철길마을과 항구를 같이 보는 2박 3일\", \"duration\": \"2박 3일\", \"audience\": \"사진을 찍는 손님 · 차량 필요\", \"why\": \"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.\", \"days\": [{\"label\": \"첫째 날\", \"startTime\": \"16:00\", \"stops\": [{\"name\": \"체크인 · 스테이 머뭄\", \"minutes\": 36, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다.\"}, {\"name\": \"오후 · 경암동 철길마을\", \"minutes\": 45, \"moveMinutes\": 12, \"latitude\": 35.981352, \"longitude\": 126.7362331, \"note\": \"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다.\", \"searchQuery\": \"군산 경암동 철길마을\"}, {\"name\": \"오후 · 초원사진관\", \"minutes\": 27, \"moveMinutes\": 11, \"latitude\": 35.987737, \"longitude\": 126.7083826, \"note\": \"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다.\", \"searchQuery\": \"군산 초원사진관\"}, {\"name\": \"저녁 · 국제반점\", \"minutes\": 50, \"moveMinutes\": 3, \"latitude\": 35.98811, \"longitude\": 126.7108985, \"note\": \"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다.\", \"searchQuery\": \"군산 국제반점\"}, {\"name\": \"밤 산책 · 군산 내항\", \"minutes\": 36, \"moveMinutes\": 9, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다.\", \"searchQuery\": \"군산 내항\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 16, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"둘째 날\", \"startTime\": \"09:40\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 54, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다.\"}, {\"name\": \"오전 · 군산 내항 부잔교\", \"minutes\": 32, \"moveMinutes\": 16, \"latitude\": 35.9892997, \"longitude\": 126.7174508, \"note\": \"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다.\", \"searchQuery\": \"군산 내항 부잔교\"}, {\"name\": \"점심 · 한일옥\", \"minutes\": 45, \"moveMinutes\": 11, \"latitude\": 35.9874081, \"longitude\": 126.71, \"note\": \"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다.\", \"searchQuery\": \"군산 한일옥\"}, {\"name\": \"오후 · 째보선창\", \"minutes\": 45, \"moveMinutes\": 13, \"latitude\": 35.9875345, \"longitude\": 126.7199875, \"note\": \"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다.\", \"searchQuery\": \"군산 째보선창\"}, {\"name\": \"오후 · 해망굴\", \"minutes\": 30, \"moveMinutes\": 21, \"latitude\": 35.9913181, \"longitude\": 126.705, \"note\": \"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다.\", \"searchQuery\": \"군산 해망굴\"}, {\"name\": \"쉼 · 스테이 머뭄\", \"minutes\": 190, \"moveMinutes\": 8, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다.\"}, {\"name\": \"저녁 · 명월갈비\", \"minutes\": 68, \"moveMinutes\": 5, \"latitude\": 35.9863914, \"longitude\": 126.7101663, \"note\": \"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다.\", \"searchQuery\": \"군산 명월갈비\"}, {\"name\": \"머뭄으로 · 스테이 머뭄\", \"minutes\": 27, \"moveMinutes\": 5, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다.\"}]}, {\"label\": \"셋째 날\", \"startTime\": \"09:30\", \"stops\": [{\"name\": \"아침 · 스테이 머뭄\", \"minutes\": 68, \"moveMinutes\": 0, \"latitude\": 35.9864175, \"longitude\": 126.7061254, \"note\": \"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다.\"}, {\"name\": \"오전 · 구 군산세관 본관\", \"minutes\": 27, \"moveMinutes\": 13, \"latitude\": 35.9915088, \"longitude\": 126.7135, \"note\": \"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다.\", \"searchQuery\": \"구 군산세관\"}, {\"name\": \"점심 · 일흥옥\", \"minutes\": 45, \"moveMinutes\": 12, \"latitude\": 35.9851498, \"longitude\": 126.7091839, \"note\": \"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다.\", \"searchQuery\": \"군산 일흥옥\"}, {\"name\": \"오후 · 군산근대미술관\", \"minutes\": 40, \"moveMinutes\": 8, \"latitude\": 35.9899368, \"longitude\": 126.7108, \"note\": \"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다.\", \"searchQuery\": \"군산근대미술관\"}]}]}]}"}, {"id": "story", "name": "군산 이야기", "enabled": true, "locked": false}, {"id": "reading", "name": "군산 읽기", "enabled": false, "locked": false, "data": "{\"kind\": \"reading\", \"version\": 1, \"title\": \"군산 읽기\", \"subtitle\": \"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지\", \"groups\": [{\"name\": \"문학\", \"note\": \"이 도시를 쓴 사람과 그 자리\"}, {\"name\": \"섬과 바다\", \"note\": \"다리를 건너면 나오는 것\"}, {\"name\": \"역사\", \"note\": \"연표에 안 들어간 이야기\"}, {\"name\": \"장소\", \"note\": \"걸어서 또는 차로 닿는 자리\"}, {\"name\": \"음식과 생활\", \"note\": \"이 도시가 먹는 것\"}], \"items\": [{\"group\": \"문학\", \"title\": \"채만식 『탁류』\", \"body\": \"일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, 가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. 금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 이야기가 펼쳐집니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%20%E3%80%8E%ED%83%81%EB%A5%98%E3%80%8F%20%EA%B5%B0%EC%82%B0\"}, \"year\": 1937}, {\"group\": \"문학\", \"title\": \"백릉 채만식\", \"body\": \"1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 호는 백릉이고, 잡지에 작품을 발표하며 문단에 나서 세태를 꼬집는 풍자 소설로 이름을 알렸습니다. 광복 뒤에는 고향 인근에 머물다 1950년 세상을 떠났습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%B0%B1%EB%A6%89%20%EC%B1%84%EB%A7%8C%EC%8B%9D%20%EA%B5%B0%EC%82%B0\"}, \"year\": 1902}, {\"group\": \"문학\", \"title\": \"채만식문학관\", \"body\": \"금강 하구 옆, 작가가 나고 자란 임피 인근에 선 문학관입니다. 생애를 따라가는 전시실과 『탁류』를 비롯한 작품의 여러 판본을 한자리에서 볼 수 있습니다. 군산이 낳은 다른 작가·시인을 소개하는 자리도 함께 있습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%EB%AC%B8%ED%95%99%EA%B4%80%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"문학\", \"title\": \"『탁류』라는 제목\", \"body\": \"금강 하류의 흙탕물을 가리키는 말이자, 그 물살에 휩쓸려 가는 사람들을 함께 가리키는 이중의 제목입니다. 맑았던 강물이 하구에 이르러 흐려지듯, 소설 속 인물들도 도시의 욕망 앞에서 하나씩 무너져 갑니다. 신문에 회를 나눠 연재된 소설이라, 그 시절 독자들은 다음 이야기를 기다리며 읽었습니다. 제목 하나에 배경과 줄거리를 함께 담은 셈이라, 채만식문학관에서도 이 제목의 뜻부터 설명을 시작합니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%E3%80%8E%ED%83%81%EB%A5%98%E3%80%8F%EB%9D%BC%EB%8A%94%20%EC%A0%9C%EB%AA%A9%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"문학\", \"title\": \"채만식의 풍자\", \"body\": \"웃기면서 찌르는 문장이 이 작가의 방식입니다. 『레디메이드 인생』·『태평천하』 같은 다른 대표작에서도 이어지는 태도로, 식민지 도시의 부조리를 인물의 우스꽝스러운 말과 행동으로 드러냅니다. 개항장 군산에서 본 것이 그 풍자의 바탕에 있습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%EC%9D%98%20%ED%92%8D%EC%9E%90%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"문학\", \"title\": \"탁류길\", \"body\": \"소설 속 공간을 실제 걷는 길로 이은 군산 스탬프투어 코스입니다. 미두장이 있던 자리와 째보선창, 부잔교를 차례로 지나며 소설의 배경을 몸으로 확인합니다. 원도심 구간이라 머뭄에서 걸어 닿습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%ED%83%81%EB%A5%98%EA%B8%B8%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"문학\", \"title\": \"조정래 『아리랑』과 아리랑길\", \"body\": \"구한말부터 광복까지, 만경강 들녘 농민들이 수탈에 맞서는 이야기를 그린 대하소설입니다. 군산·김제 일대가 무대라 스탬프투어에도 같은 이름의 코스가 있습니다. 여러 권에 걸친 긴 이야기라, 한 도시가 아니라 호남평야 들녘 전체를 무대로 다룹니다. 아리랑길은 실제 그 들녘과 마을 길을 따라 걷도록 짜여 있어, 소설이 그린 풍경을 걸음으로 확인할 수 있습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%A1%B0%EC%A0%95%EB%9E%98%20%E3%80%8E%EC%95%84%EB%A6%AC%EB%9E%91%E3%80%8F%EA%B3%BC%20%EC%95%84%EB%A6%AC%EB%9E%91%EA%B8%B8%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"문학\", \"title\": \"군산이 낳은 시인들\", \"body\": \"고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 일제강점기부터 지금까지 세대를 이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 한 사람씩 생애와 활동을 봅니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%EC%9D%B4%20%EB%82%B3%EC%9D%80%20%EC%8B%9C%EC%9D%B8%EB%93%A4\"}}, {\"group\": \"섬과 바다\", \"title\": \"고군산군도\", \"body\": \"예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, 그 뒤로 무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 있습니다. 섬마다 성격이 달라 하루로는 다 못 봅니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B3%A0%EA%B5%B0%EC%82%B0%EA%B5%B0%EB%8F%84\"}}, {\"group\": \"섬과 바다\", \"title\": \"선유도\", \"body\": \"고군산군도의 대표 섬입니다. 명사십리라 불리는 백사장을 걷고, 망주봉 아래 해변에서 스카이라인(짚라인)을 탑니다. 대장봉에 오르면 다도해로 지는 해를 한눈에 봅니다. 머뭄에서 차로 한 시간 남짓입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%84%A0%EC%9C%A0%EB%8F%84%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"섬과 바다\", \"title\": \"장자도\", \"body\": \"선유도와 다리로 이어진 작은 섬입니다. 어선이 드나드는 포구를 끼고 있고, 마을 뒤 대장봉으로 오르는 등산로가 여기서 시작합니다. 산길이 가팔라도 길지 않아 왕복 한두 시간이면 오르내릴 수 있습니다. 정상에 서면 고군산군도의 섬들이 바다 위에 늘어선 모습이 한눈에 들어옵니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9E%A5%EC%9E%90%EB%8F%84%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"섬과 바다\", \"title\": \"무녀도\", \"body\": \"고군산군도의 섬 하나입니다. 춤추는 무녀의 형상을 닮았다는 이야기가 이름의 유래로 전해지고, 작은 해수욕장이 딸려 있습니다. 신시도에서 선유도로 넘어가는 다리 길목에 있어, 고군산군도를 도는 길에 자연히 거쳐 가게 됩니다. 관광객이 몰리는 선유도보다 한적해, 잠시 차를 세우고 바다를 보기에 좋습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%AC%B4%EB%85%80%EB%8F%84%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"섬과 바다\", \"title\": \"신시도\", \"body\": \"새만금 방조제가 육지와 처음 맞닿는 섬입니다. 고군산군도에서 몸집이 큰 섬 가운데 하나로, 대각산에 오르면 방조제와 인근 섬들이 한눈에 들어옵니다. 방조제 도로를 타고 들어오면 가장 먼저 만나는 섬이라, 고군산군도 여행이 시작되는 자리이기도 합니다. 마을과 해변이 함께 있어, 다른 섬으로 건너가기 전에 잠깐 들러 쉬어 가기도 합니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%8B%A0%EC%8B%9C%EB%8F%84%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"섬과 바다\", \"title\": \"비응항\", \"body\": \"새만금 방조제가 시작하는 항구입니다. 젓갈과 수산물을 파는 상가가 늘어서 있고, 서해 관광의 관문으로 씁니다. 어선이 실어 온 수산물이 그날그날 상가로 올라와, 싱싱한 해산물을 바로 사거나 맛볼 수 있습니다. 항구 너머로 방조제가 곧게 뻗어 있어, 바다 쪽으로 나가기 전 들르기 좋은 자리입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%B9%84%EC%9D%91%ED%95%AD%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"역사\", \"title\": \"미곡 수탈과 군산항\", \"body\": \"일제강점기 호남평야에서 거둔 쌀이 이 항구로 모여 일본으로 나갔습니다. 부잔교와 그 옆의 대형 창고들이 그 통로였고, 쌀값을 매기던 미두장이 도시 경제의 중심에 있었습니다. 『탁류』가 그 시절 풍경을 그대로 배경 삼았습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%AF%B8%EA%B3%A1%20%EC%88%98%ED%83%88%EA%B3%BC%20%EA%B5%B0%EC%82%B0%ED%95%AD\"}}, {\"group\": \"역사\", \"title\": \"째보선창\", \"body\": \"내항 옆 선창입니다. 언청이 뱃사공의 사연이 이름의 유래로 전해지고, 『탁류』의 인물들이 오가던 자리이기도 합니다. 지금은 배 대신 낡은 창고가 남았습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%A7%B8%EB%B3%B4%EC%84%A0%EC%B0%BD%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"역사\", \"title\": \"해망굴\", \"body\": \"일제강점기에 뚫린 굴로, 시내와 항구를 잇습니다. 차 한 대가 겨우 지날 만큼 좁아 지금은 사람이 걸어서 통과해 반대편 항구 쪽으로 나옵니다. 굴 안은 서늘하고 어둑해 걸어 지나가는 동안 도시의 소리가 잠깐 끊깁니다. 반대편으로 나오면 바로 내항이 보여, 원도심과 항구를 잇는 지름길로 지금도 쓰입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%ED%95%B4%EB%A7%9D%EA%B5%B4%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"역사\", \"title\": \"경암동 철길\", \"body\": \"1944년에 놓인 철길입니다. 인근 제지공장의 화물을 나르던 산업철도였고, 지금은 열차가 다니지 않지만 선로가 집 앞 골목을 그대로 지나가는 풍경만 남았습니다. 선로 양옆으로 집과 가게가 바짝 붙어 있어, 열차가 다니던 시절에는 문을 열면 바로 철길이었습니다. 지금은 옛 교복을 빌려 입고 사진을 찍거나 선로를 따라 걷는 자리로 쓰입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B2%BD%EC%95%94%EB%8F%99%20%EC%B2%A0%EA%B8%B8%20%EA%B5%B0%EC%82%B0\"}, \"year\": 1944}, {\"group\": \"역사\", \"title\": \"군산항\", \"body\": \"1899년 개항 이후 근대 산업항으로 자란 항구입니다. 만남과 이별, 물류와 근대사가 한자리에 겹치며 도시의 성격이 여기서 정해졌습니다. 개항 뒤로는 쌀과 물자가 이 항구를 거쳐 오갔고, 그만큼 많은 사람이 들고 났습니다. 지금 원도심에 남은 근대 건축물 대부분이 이 항구를 중심으로 들어선 것들입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%ED%95%AD\"}, \"year\": 1899}, {\"group\": \"역사\", \"title\": \"군산 3·5 만세운동\", \"body\": \"1919년 3월 5일, 영명학교 학생들이 중심이 되어 원도심에서 벌인 만세운동입니다. 전국 3·1운동이 퍼져 가던 무렵 이 도시가 어떻게 호응했는지를 보여주는 사건입니다. 학생들은 미리 태극기와 독립선언서를 준비해 장날에 맞춰 거리로 나섰습니다. 지금도 원도심을 걷다 보면 이 사건을 알리는 표지판과 기록을 곳곳에서 만납니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%203%C2%B75%20%EB%A7%8C%EC%84%B8%EC%9A%B4%EB%8F%99\"}, \"year\": 1919}, {\"group\": \"역사\", \"title\": \"임피역\", \"body\": \"옛 모습을 그대로 간직한 목조 간이역입니다. 근대 철도 건축의 흔적으로 등록문화재가 됐고, 지금은 열차가 서지 않고 자리만 지킵니다. 작은 대합실과 승강장이 지어질 당시 모습 그대로 남아 있어, 옛 기차역의 분위기를 눈으로 확인할 수 있습니다. 원도심에서 조금 떨어져 있어, 차로 이동하는 여정에 잠시 들르기 좋은 자리입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9E%84%ED%94%BC%EC%97%AD%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"군산근대역사박물관\", \"body\": \"해양물류의 역사와 근대 생활 풍경을 층별로 나눠 보여주는 박물관입니다. 3층부터 내려오며 항구도시의 근대사를 훑고, 도시를 걷기 전에 여기서 한 번 배경을 잡고 나섭니다. 전시실 안에는 당시 거리와 상점을 재현한 공간도 있어, 사진으로만 보던 풍경을 실제 크기로 걸어 볼 수 있습니다. 원도심 답사의 시작점으로 삼기 좋은 위치라, 관람을 마치고 바로 근처 근대문화유산으로 이어 걷습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%EA%B7%BC%EB%8C%80%EC%97%AD%EC%82%AC%EB%B0%95%EB%AC%BC%EA%B4%80\"}}, {\"group\": \"장소\", \"title\": \"군산 내항과 부잔교\", \"body\": \"조수 간만의 차가 큰 서해에서 배를 대기 위해 만든 뜬다리(부잔교)입니다. 물이 오르내려도 다리 자체가 함께 움직여 배가 늘 수평으로 닿습니다. 다리 위를 끝까지 걸어 나갔다 돌아옵니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%20%EB%82%B4%ED%95%AD%EA%B3%BC%20%EB%B6%80%EC%9E%94%EA%B5%90\"}, \"year\": 1926}, {\"group\": \"장소\", \"title\": \"동국사\", \"body\": \"국내에 남은 몇 안 되는 일본식 사찰 건축입니다. 대웅전 지붕과 마당의 배치에서 일본 사찰 양식이 그대로 드러나, 개항기 흔적을 건물로 보여주는 자리입니다. 지금도 실제로 예불을 올리는 절이라, 관람 중에는 조용히 둘러보는 것이 예의입니다. 마당의 소철과 종루까지 그 시절 배치가 크게 바뀌지 않아, 사진에 담기 좋은 자리로도 알려져 있습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%8F%99%EA%B5%AD%EC%82%AC%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"월명공원\", \"body\": \"시내와 바다를 함께 내려다보는 공원입니다. 수시탑과 문인들의 시비가 산책로를 따라 서 있고, 계절마다 오르는 길의 풍경이 달라집니다. 정상 부근까지 차로 오를 수 있어, 가볍게 걷고 싶은 사람과 전망만 보고 싶은 사람 모두에게 맞습니다. 밤에는 시내 불빛이 내려다보여, 낮과는 다른 풍경을 봅니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9B%94%EB%AA%85%EA%B3%B5%EC%9B%90%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"은파호수공원\", \"body\": \"호수를 두르는 산책길입니다. 겨울이면 조명을 켠 벚나무 길이 볼거리가 되고, 봄에는 같은 길이 벚꽃길로 바뀝니다. 저녁에 한 바퀴 돌기 좋습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9D%80%ED%8C%8C%ED%98%B8%EC%88%98%EA%B3%B5%EC%9B%90%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"금강하굿둑\", \"body\": \"금강과 서해가 만나는 자리에 놓은 둑입니다. 강과 바다의 경계를 사람 손으로 그은 곳이고, 둑 위로 충남 서천과 전북 군산을 잇는 길이 나 있습니다. 둑을 따라 걷거나 차로 건너면서 한쪽은 강물, 다른 한쪽은 바닷물이 나뉘는 모습을 눈으로 확인할 수 있습니다. 철새가 많이 모이는 자리로도 알려져 있어, 계절에 따라 다른 새를 봅니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B8%88%EA%B0%95%ED%95%98%EA%B5%BF%EB%91%91%20%EA%B5%B0%EC%82%B0\"}, \"year\": 1990}, {\"group\": \"장소\", \"title\": \"말랭이마을\", \"body\": \"경사진 언덕을 따라 계단식으로 집이 들어선 마을입니다. 골목 담장마다 그림이 그려져 있고, 도시재생 사업으로 되살아난 자리입니다. 계단과 골목이 좁고 가팔라, 걷는 속도를 늦추고 천천히 둘러보게 됩니다. 언덕 위에서는 원도심과 항구 쪽 풍경이 함께 내려다보입니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%A7%90%EB%9E%AD%EC%9D%B4%EB%A7%88%EC%9D%84%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"장미갤러리\", \"body\": \"근대역사문화거리 안의 전시 공간입니다. 쌀을 쌓아 두던 옛 창고 건물을 고쳐 써서, 건물 자체가 그 시절 물류의 흔적입니다. 높은 천장과 두꺼운 벽 같은 창고의 구조를 그대로 살려, 전시 공간이면서 건물 자체도 볼거리입니다. 인근의 다른 근대 건축물과 나란히 있어, 걸어서 함께 둘러보기 좋습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9E%A5%EB%AF%B8%EA%B0%A4%EB%9F%AC%EB%A6%AC%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"진포해양테마공원\", \"body\": \"진포대첩의 기억을 항구 옆에 펼쳐 둔 공원입니다. 퇴역한 군함과 항공기를 실제로 가져다 놓아, 전시장이 아니라 야외에서 크기를 몸으로 느낄 수 있습니다. 고려 말 왜구를 화포로 물리친 진포대첩의 무대가 바로 이 앞바다입니다. 아이와 함께라면 직접 올라타 볼 수 있는 전시물도 있어, 역사 이야기를 몸으로 체험하게 됩니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%A7%84%ED%8F%AC%ED%95%B4%EC%96%91%ED%85%8C%EB%A7%88%EA%B3%B5%EC%9B%90%20%EA%B5%B0%EC%82%B0\"}}, {\"group\": \"장소\", \"title\": \"군산예술의전당\", \"body\": \"지역 공연을 올리는 문화 시설입니다. 원도심 답사에서 벗어나 공연 한 편을 함께 일정에 넣고 싶을 때 확인해 볼 자리입니다. 크고 작은 공연장이 함께 있어, 음악회부터 연극까지 다양한 공연이 열립니다. 원도심에서 차로 이동해야 하는 거리라, 일정에 여유가 있을 때 넣기 좋습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%EC%98%88%EC%88%A0%EC%9D%98%EC%A0%84%EB%8B%B9\"}}, {\"group\": \"음식과 생활\", \"title\": \"군산 짬뽕\", \"body\": \"개항 이후 이 도시에 자리 잡은 화교 공동체가 남긴 음식 문화입니다. 빈해원 · 국제반점처럼 오래된 중식당이 아직 그 자리를 지키고, 짬뽕 한 그릇에 항구도시의 내력이 담겨 있다고들 말합니다. 항구를 오가던 뱃사람과 노동자들이 빠르고 든든하게 먹을 수 있는 음식으로 자리 잡았다고 전해집니다. 가게마다 국물 색과 맛이 조금씩 달라, 여러 곳을 비교해 먹어 보는 재미도 있습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%20%EC%A7%AC%EB%BD%95\"}}, {\"group\": \"음식과 생활\", \"title\": \"박대와 군산 밥상\", \"body\": \"서해에서 나는 박대라는 생선을 말려 구워 냅니다. 살이 도톰하고 기름기가 적어 짭조름하게 구우면 밥반찬으로 잘 맞고, 이 지역 백반집 상에 자주 오릅니다. 예전에는 흔해 서민 밥상에 자주 오르던 생선이었다고 전해집니다. 말려서 굽는 조리법이라 오래 두고 먹을 수 있어, 지금도 반찬이나 선물용으로 찾는 사람이 많습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%B0%95%EB%8C%80%EC%99%80%20%EA%B5%B0%EC%82%B0%20%EB%B0%A5%EC%83%81\"}}, {\"group\": \"음식과 생활\", \"title\": \"이성당\", \"body\": \"국내에서 손꼽히게 오래된 빵집 가운데 하나입니다. 단팥빵과 야채빵이 대표 메뉴로, 갓 나오는 시간에 맞추지 않으면 줄을 서야 할 만큼 찾는 사람이 많습니다. 일제강점기 부터 이어진 자리에서 지금까지 영업을 이어 오고 있어, 군산을 대표하는 오래된 가게로 꼽힙니다. 이른 아침부터 줄이 늘어서는 날이 많아, 여유 있게 들르려면 시간을 넉넉히 잡는 편이 낫습니다.\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%9D%B4%EC%84%B1%EB%8B%B9%20%EA%B5%B0%EC%82%B0\"}}]}"}, {"id": "songs", "name": "가요 다방", "enabled": false, "locked": false, "data": "{\"kind\": \"songs\", \"version\": 1, \"title\": \"가요 다방\", \"subtitle\": \"군산을 노래한 25곡 — 눌러서 들어 보세요\", \"items\": [{\"title\": \"잘 있거라 군산항\", \"artist\": \"안다성\", \"labelColor\": \"#2f6b4f\", \"story\": \"월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9E%98%20%EC%9E%88%EA%B1%B0%EB%9D%BC%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%95%88%EB%8B%A4%EC%84%B1\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"손석우\", \"composer\": \"손석우\", \"year\": 1963}, {\"title\": \"헤어진 군산항\", \"artist\": \"박제연\", \"labelColor\": \"#2a6053\", \"story\": \"비 내리는 밤 부두에서 헤어지는 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%97%A4%EC%96%B4%EC%A7%84%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B0%95%EC%A0%9C%EC%97%B0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송운선\", \"composer\": \"송운선\", \"year\": 1965}, {\"title\": \"님 떠난 군산항\", \"artist\": \"이미자\", \"labelColor\": \"#1d3c74\", \"story\": \"떠나는 배를 보내는 항구의 정서를 담았습니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%8B%98%20%EB%96%A0%EB%82%9C%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"조형식\", \"composer\": \"백영호\", \"year\": 1966}, {\"title\": \"금강 나그네\", \"artist\": \"이미자\", \"labelColor\": \"#d4551f\", \"story\": \"금강 하구와 군산항 부두를 배경으로 한 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%20%EB%82%98%EA%B7%B8%EB%84%A4%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이병훈\", \"composer\": \"박춘석\", \"year\": 1966}, {\"title\": \"잘 있거라 군산항구\", \"artist\": \"최정자\", \"labelColor\": \"#8a4b2a\", \"story\": \"떠나는 사람의 말로 부르는 이별가입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9E%98%20%EC%9E%88%EA%B1%B0%EB%9D%BC%20%EA%B5%B0%EC%82%B0%ED%95%AD%EA%B5%AC%20%EC%B5%9C%EC%A0%95%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최치수\", \"composer\": \"유금춘\", \"year\": 1967}, {\"title\": \"군산 마도로스\", \"artist\": \"이상열\", \"labelColor\": \"#334b6d\", \"story\": \"항구를 드나들던 뱃사람의 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EB%A7%88%EB%8F%84%EB%A1%9C%EC%8A%A4%20%EC%9D%B4%EC%83%81%EC%97%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"year\": 1967}, {\"title\": \"군산 부르스\", \"artist\": \"유주용\", \"labelColor\": \"#4a3f6b\", \"story\": \"1960년대 말 군산 거리의 분위기를 담은 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EB%B6%80%EB%A5%B4%EC%8A%A4%20%EC%9C%A0%EC%A3%BC%EC%9A%A9\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"반야월\", \"composer\": \"강도미\", \"year\": 1969}, {\"title\": \"이별의 군산항 부두\", \"artist\": \"최정자\", \"labelColor\": \"#6b2f3a\", \"story\": \"부두에서의 이별을 다시 한 번 부른 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%9D%B4%EB%B3%84%EC%9D%98%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B6%80%EB%91%90%20%EC%B5%9C%EC%A0%95%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최치수\", \"composer\": \"유금춘\", \"year\": 1969}, {\"title\": \"군산항 부르스\", \"artist\": \"최하우\", \"labelColor\": \"#2f5b6b\", \"story\": \"1980년대에 나온 군산항 노래입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%ED%95%AD%20%EB%B6%80%EB%A5%B4%EC%8A%A4%20%EC%B5%9C%ED%95%98%EC%9A%B0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"최하우\", \"composer\": \"김성근\", \"year\": 1984}, {\"title\": \"나의 사랑 군산이여\", \"artist\": \"오단비\", \"labelColor\": \"#a34a2a\", \"story\": \"군산을 향한 애정을 정면으로 부르는 곡입니다.\", \"verified\": \"확인\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%82%98%EC%9D%98%20%EC%82%AC%EB%9E%91%20%EA%B5%B0%EC%82%B0%EC%9D%B4%EC%97%AC%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"정종택\", \"year\": 1989}, {\"title\": \"군산항 엘레지\", \"artist\": \"오단비\", \"labelColor\": \"#7a3b52\", \"story\": \"항구 도시의 비가입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%97%98%EB%A0%88%EC%A7%80%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"정종택\"}, {\"title\": \"선유도 아가씨\", \"artist\": \"오단비\", \"labelColor\": \"#2b6b7a\", \"story\": \"고군산군도 선유도를 배경으로 한 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%84%A0%EC%9C%A0%EB%8F%84%20%EC%95%84%EA%B0%80%EC%94%A8%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"군산찬가\", \"artist\": \"오단비\", \"labelColor\": \"#c8873a\", \"story\": \"은파와 새만금이 가사에 나오는 군산 찬가입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%B0%AC%EA%B0%80%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"오성산\", \"artist\": \"오단비\", \"labelColor\": \"#4f6b3a\", \"story\": \"금강 하구를 내려다보는 오성산을 노래했습니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%98%A4%EC%84%B1%EC%82%B0%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"이수\", \"composer\": \"서승일\"}, {\"title\": \"함께하는 군산\", \"artist\": \"오은주\", \"labelColor\": \"#b5603a\", \"story\": \"군산시가 제작한 음반의 첫 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94%20%EA%B5%B0%EC%82%B0%20%EC%98%A4%EC%9D%80%EC%A3%BC\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"김동찬\", \"composer\": \"김동찬\"}, {\"title\": \"새만금 연가\", \"artist\": \"채미영\", \"labelColor\": \"#356b8a\", \"story\": \"새만금을 소재로 한 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EC%83%88%EB%A7%8C%EA%B8%88%20%EC%97%B0%EA%B0%80%20%EC%B1%84%EB%AF%B8%EC%98%81\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"김동찬\", \"composer\": \"김동찬 · 김수환\"}, {\"title\": \"군산시민의 노래\", \"artist\": \"오단비\", \"labelColor\": \"#5a5a4a\", \"story\": \"금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%8B%9C%EB%AF%BC%EC%9D%98%20%EB%85%B8%EB%9E%98%20%EC%98%A4%EB%8B%A8%EB%B9%84\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"군산문화원\", \"composer\": \"군산예총\"}, {\"title\": \"군산 아줌마\", \"artist\": \"박솔미\", \"labelColor\": \"#a3562a\", \"story\": \"선창가 거리와 새만금이 함께 나오는 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EC%95%84%EC%A4%8C%EB%A7%88%20%EB%B0%95%EC%86%94%EB%AF%B8\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송두호\", \"composer\": \"송두호\"}, {\"title\": \"내 고향 군산항\", \"artist\": \"정애란\", \"labelColor\": \"#6b4a2a\", \"story\": \"고향으로서의 군산항을 부른 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EB%82%B4%20%EA%B3%A0%ED%96%A5%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EC%A0%95%EC%95%A0%EB%9E%80\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"정애란\", \"composer\": \"정애란\"}, {\"title\": \"군산 가는 길\", \"artist\": \"황상준\", \"labelColor\": \"#3a5a4a\", \"story\": \"군산으로 향하는 길을 제목으로 삼은 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EA%B0%80%EB%8A%94%20%EA%B8%B8%20%ED%99%A9%EC%83%81%EC%A4%80\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"군산 추억\", \"artist\": \"임인건\", \"labelColor\": \"#4a4a6b\", \"story\": \"재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%20%EC%B6%94%EC%96%B5%20%EC%9E%84%EC%9D%B8%EA%B1%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"군산에서\", \"artist\": \"임인건\", \"labelColor\": \"#3f5f6b\", \"story\": \"같은 연주자가 군산에서 받은 인상을 담았습니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B5%B0%EC%82%B0%EC%97%90%EC%84%9C%20%EC%9E%84%EC%9D%B8%EA%B1%B4\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"헤어진 군산항\", \"artist\": \"김창환\", \"labelColor\": \"#2a6053\", \"story\": \"군산시 제작 음반에 다시 실린 판입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%ED%97%A4%EC%96%B4%EC%A7%84%20%EA%B5%B0%EC%82%B0%ED%95%AD%20%EA%B9%80%EC%B0%BD%ED%99%98\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}, \"lyricist\": \"송운선\", \"composer\": \"송운선\"}, {\"title\": \"금강 아가씨\", \"artist\": \"이미자\", \"labelColor\": \"#d4551f\", \"story\": \"금강을 배경으로 한 이미자의 또 다른 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%20%EC%95%84%EA%B0%80%EC%94%A8%20%EC%9D%B4%EB%AF%B8%EC%9E%90\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"title\": \"금강의 비가\", \"artist\": \"전승애\", \"labelColor\": \"#7a4a6b\", \"story\": \"금강을 제목에 올린 곡입니다.\", \"verified\": \"확인필요\", \"listenUrl\": \"https://www.youtube.com/results?search_query=%EA%B8%88%EA%B0%95%EC%9D%98%20%EB%B9%84%EA%B0%80%20%EC%A0%84%EC%8A%B9%EC%95%A0\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}]}"}, {"id": "daily", "name": "오늘의 한 장", "enabled": false, "locked": false, "data": "{\"kind\": \"daily\", \"version\": 1, \"title\": \"오늘의 한 장\", \"subtitle\": \"군산의 노래와 문학 39장 — 날마다 한 장씩 넘어갑니다\", \"items\": [{\"monthDay\": \"01-01\", \"category\": \"문학\", \"title\": \"채만식 『탁류』\", \"body\": \"일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, 가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. 금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 이야기가 펼쳐집니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%20%E3%80%8E%ED%83%81%EB%A5%98%E3%80%8F%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"01-10\", \"category\": \"대중가요\", \"title\": \"「잘 있거라 군산항」 · 안다성\", \"body\": \"1963년 곡. 작사 손석우 · 작곡 손석우. 월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"01-20\", \"category\": \"문학\", \"title\": \"백릉 채만식\", \"body\": \"1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 호는 백릉이고, 잡지에 작품을 발표하며 문단에 나서 세태를 꼬집는 풍자 소설로 이름을 알렸습니다. 광복 뒤에는 고향 인근에 머물다 1950년 세상을 떠났습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EB%B0%B1%EB%A6%89%20%EC%B1%84%EB%A7%8C%EC%8B%9D%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"01-29\", \"category\": \"대중가요\", \"title\": \"「헤어진 군산항」 · 박제연\", \"body\": \"1965년 곡. 작사 송운선 · 작곡 송운선. 비 내리는 밤 부두에서 헤어지는 노래입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"02-07\", \"category\": \"대중가요\", \"title\": \"「님 떠난 군산항」 · 이미자\", \"body\": \"1966년 곡. 작사 조형식 · 작곡 백영호. 떠나는 배를 보내는 항구의 정서를 담았습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"02-17\", \"category\": \"문학\", \"title\": \"채만식문학관\", \"body\": \"금강 하구 옆, 작가가 나고 자란 임피 인근에 선 문학관입니다. 생애를 따라가는 전시실과 『탁류』를 비롯한 작품의 여러 판본을 한자리에서 볼 수 있습니다. 군산이 낳은 다른 작가·시인을 소개하는 자리도 함께 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%EB%AC%B8%ED%95%99%EA%B4%80%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"02-26\", \"category\": \"대중가요\", \"title\": \"「금강 나그네」 · 이미자\", \"body\": \"1966년 곡. 작사 이병훈 · 작곡 박춘석. 금강 하구와 군산항 부두를 배경으로 한 곡입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"03-08\", \"category\": \"대중가요\", \"title\": \"「잘 있거라 군산항구」 · 최정자\", \"body\": \"1967년 곡. 작사 최치수 · 작곡 유금춘. 떠나는 사람의 말로 부르는 이별가입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"03-17\", \"category\": \"문학\", \"title\": \"『탁류』라는 제목\", \"body\": \"금강 하류의 흙탕물을 가리키는 말이자, 그 물살에 휩쓸려 가는 사람들을 함께 가리키는 이중의 제목입니다. 맑았던 강물이 하구에 이르러 흐려지듯, 소설 속 인물들도 도시의 욕망 앞에서 하나씩 무너져 갑니다. 신문에 회를 나눠 연재된 소설이라, 그 시절 독자들은 다음 이야기를 기다리며 읽었습니다. 제목 하나에 배경과 줄거리를 함께 담은 셈이라, 채만식문학관에서도 이 제목의 뜻부터 설명을 시작합니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%E3%80%8E%ED%83%81%EB%A5%98%E3%80%8F%EB%9D%BC%EB%8A%94%20%EC%A0%9C%EB%AA%A9%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"03-26\", \"category\": \"대중가요\", \"title\": \"「군산 마도로스」 · 이상열\", \"body\": \"1967년 곡. 항구를 드나들던 뱃사람의 노래입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"04-05\", \"category\": \"대중가요\", \"title\": \"「군산 부르스」 · 유주용\", \"body\": \"1969년 곡. 작사 반야월 · 작곡 강도미. 1960년대 말 군산 거리의 분위기를 담은 곡입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"04-14\", \"category\": \"문학\", \"title\": \"채만식의 풍자\", \"body\": \"웃기면서 찌르는 문장이 이 작가의 방식입니다. 『레디메이드 인생』·『태평천하』 같은 다른 대표작에서도 이어지는 태도로, 식민지 도시의 부조리를 인물의 우스꽝스러운 말과 행동으로 드러냅니다. 개항장 군산에서 본 것이 그 풍자의 바탕에 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%B1%84%EB%A7%8C%EC%8B%9D%EC%9D%98%20%ED%92%8D%EC%9E%90%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"04-23\", \"category\": \"대중가요\", \"title\": \"「이별의 군산항 부두」 · 최정자\", \"body\": \"1969년 곡. 작사 최치수 · 작곡 유금춘. 부두에서의 이별을 다시 한 번 부른 곡입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"05-03\", \"category\": \"문학\", \"title\": \"탁류길\", \"body\": \"소설 속 공간을 실제 걷는 길로 이은 군산 스탬프투어 코스입니다. 미두장이 있던 자리와 째보선창, 부잔교를 차례로 지나며 소설의 배경을 몸으로 확인합니다. 원도심 구간이라 머뭄에서 걸어 닿습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%ED%83%81%EB%A5%98%EA%B8%B8%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"05-12\", \"category\": \"대중가요\", \"title\": \"「군산항 부르스」 · 최하우\", \"body\": \"1984년 곡. 작사 최하우 · 작곡 김성근. 1980년대에 나온 군산항 노래입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"05-21\", \"category\": \"대중가요\", \"title\": \"「나의 사랑 군산이여」 · 오단비\", \"body\": \"1989년 곡. 작사 이수 · 작곡 정종택. 군산을 향한 애정을 정면으로 부르는 곡입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"05-31\", \"category\": \"문학\", \"title\": \"조정래 『아리랑』과 아리랑길\", \"body\": \"구한말부터 광복까지, 만경강 들녘 농민들이 수탈에 맞서는 이야기를 그린 대하소설입니다. 군산·김제 일대가 무대라 스탬프투어에도 같은 이름의 코스가 있습니다. 여러 권에 걸친 긴 이야기라, 한 도시가 아니라 호남평야 들녘 전체를 무대로 다룹니다. 아리랑길은 실제 그 들녘과 마을 길을 따라 걷도록 짜여 있어, 소설이 그린 풍경을 걸음으로 확인할 수 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EC%A1%B0%EC%A0%95%EB%9E%98%20%E3%80%8E%EC%95%84%EB%A6%AC%EB%9E%91%E3%80%8F%EA%B3%BC%20%EC%95%84%EB%A6%AC%EB%9E%91%EA%B8%B8%20%EA%B5%B0%EC%82%B0\"}}, {\"monthDay\": \"06-09\", \"category\": \"대중가요\", \"title\": \"「군산항 엘레지」 · 오단비\", \"body\": \"작사 이수 · 작곡 정종택. 항구 도시의 비가입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"06-18\", \"category\": \"대중가요\", \"title\": \"「선유도 아가씨」 · 오단비\", \"body\": \"작사 이수 · 작곡 서승일. 고군산군도 선유도를 배경으로 한 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"06-28\", \"category\": \"문학\", \"title\": \"군산이 낳은 시인들\", \"body\": \"고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 일제강점기부터 지금까지 세대를 이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 한 사람씩 생애와 활동을 봅니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"네이버에서 찾아보기\", \"url\": \"https://search.naver.com/search.naver?query=%EA%B5%B0%EC%82%B0%EC%9D%B4%20%EB%82%B3%EC%9D%80%20%EC%8B%9C%EC%9D%B8%EB%93%A4\"}}, {\"monthDay\": \"07-07\", \"category\": \"대중가요\", \"title\": \"「군산찬가」 · 오단비\", \"body\": \"작사 이수 · 작곡 서승일. 은파와 새만금이 가사에 나오는 군산 찬가입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"07-17\", \"category\": \"대중가요\", \"title\": \"「오성산」 · 오단비\", \"body\": \"작사 이수 · 작곡 서승일. 금강 하구를 내려다보는 오성산을 노래했습니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"07-26\", \"category\": \"시\", \"title\": \"시인 고은\", \"body\": \"1933– · 군산에서 태어나 60년 넘게 시를 써 온 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%EC%9D%80\"}}, {\"monthDay\": \"08-04\", \"category\": \"대중가요\", \"title\": \"「함께하는 군산」 · 오은주\", \"body\": \"작사 김동찬 · 작곡 김동찬. 군산시가 제작한 음반의 첫 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"08-14\", \"category\": \"대중가요\", \"title\": \"「새만금 연가」 · 채미영\", \"body\": \"작사 김동찬 · 작곡 김동찬 · 김수환. 새만금을 소재로 한 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"08-23\", \"category\": \"시\", \"title\": \"시인 이병훈\", \"body\": \"1925–2009 · 옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EB%B3%91%ED%9B%88_%28%EC%8B%9C%EC%9D%B8%29\"}}, {\"monthDay\": \"09-01\", \"category\": \"대중가요\", \"title\": \"「군산시민의 노래」 · 오단비\", \"body\": \"작사 군산문화원 · 작곡 군산예총. 금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"09-11\", \"category\": \"시\", \"title\": \"시인 심호택\", \"body\": \"1947–2010 · 민중의 삶을 시로 옮긴 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%8B%AC%ED%98%B8%ED%83%9D\"}}, {\"monthDay\": \"09-20\", \"category\": \"대중가요\", \"title\": \"「군산 아줌마」 · 박솔미\", \"body\": \"작사 송두호 · 작곡 송두호. 선창가 거리와 새만금이 함께 나오는 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"09-29\", \"category\": \"대중가요\", \"title\": \"「내 고향 군산항」 · 정애란\", \"body\": \"작사 정애란 · 작곡 정애란. 고향으로서의 군산항을 부른 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"10-09\", \"category\": \"시\", \"title\": \"시인 문효치\", \"body\": \"1943– · 군산에서 태어나 1966년 신춘문예로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%AC%B8%ED%9A%A8%EC%B9%98\"}}, {\"monthDay\": \"10-18\", \"category\": \"대중가요\", \"title\": \"「군산 가는 길」 · 황상준\", \"body\": \"군산으로 향하는 길을 제목으로 삼은 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"10-27\", \"category\": \"대중가요\", \"title\": \"「군산 추억」 · 임인건\", \"body\": \"재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"11-06\", \"category\": \"시\", \"title\": \"시인 이연주\", \"body\": \"1953–1992 · 짧게 활동하고 떠난 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%97%B0%EC%A3%BC_%28%EC%8B%9C%EC%9D%B8%29\"}}, {\"monthDay\": \"11-15\", \"category\": \"대중가요\", \"title\": \"「군산에서」 · 임인건\", \"body\": \"같은 연주자가 군산에서 받은 인상을 담았습니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"11-25\", \"category\": \"대중가요\", \"title\": \"「헤어진 군산항」 · 김창환\", \"body\": \"작사 송운선 · 작곡 송운선. 군산시 제작 음반에 다시 실린 판입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"12-04\", \"category\": \"시\", \"title\": \"시인 주하림\", \"body\": \"군산에서 태어나 2009년 창비신인시인상으로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A3%BC%ED%95%98%EB%A6%BC\"}}, {\"monthDay\": \"12-13\", \"category\": \"대중가요\", \"title\": \"「금강 아가씨」 · 이미자\", \"body\": \"금강을 배경으로 한 이미자의 또 다른 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}, {\"monthDay\": \"12-23\", \"category\": \"대중가요\", \"title\": \"「금강의 비가」 · 전승애\", \"body\": \"금강을 제목에 올린 곡입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"음악의 오솔길 — 군산 관련 노래\", \"url\": \"https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98\"}}]}"}, {"id": "people", "name": "인물 열전", "enabled": false, "locked": false, "data": "{\"kind\": \"people\", \"version\": 1, \"title\": \"인물 열전\", \"subtitle\": \"군산이 낳은 사람 58명\", \"items\": [{\"name\": \"채만식\", \"role\": \"소설가\", \"oneLine\": \"군산을 무대로 한 『탁류』를 썼습니다. 시내에 문학관이 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EB%A7%8C%EC%8B%9D\"}, \"years\": \"1902–1950\", \"imageUrl\": \"/assets/mirror/d902ef61234bd897.png\"}, {\"name\": \"고은\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 60년 넘게 시를 써 온 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%EC%9D%80\"}, \"years\": \"1933–\", \"imageUrl\": \"/s/stay/img/people/%EA%B3%A0%EC%9D%80.jpg\", \"imageCredit\": \"사진 Matěj Baťha · CC BY-SA 3.0\"}, {\"name\": \"김선기\", \"role\": \"언어학자\", \"oneLine\": \"호는 무돌. 한글 연구에 힘쓴 언어학자이자 교육자입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%84%A0%EA%B8%B0_%28%EC%96%B8%EC%96%B4%EC%B2%A0%ED%95%99%EC%9E%90%29\"}, \"years\": \"1907–1992\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EC%84%A0%EA%B8%B0.jpg\", \"imageCredit\": \"사진 미상Unknown author · Public domain\"}, {\"name\": \"김수미\", \"role\": \"배우\", \"oneLine\": \"「전원일기」 일용 엄니로 오래 기억되는 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%88%98%EB%AF%B8\"}, \"years\": \"1949–2024\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EC%88%98%EB%AF%B8.png\", \"imageCredit\": \"사진 Seoul Broadcasting System · CC BY 3.0\"}, {\"name\": \"송새벽\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%86%A1%EC%83%88%EB%B2%BD\"}, \"years\": \"1979–\", \"imageUrl\": \"/s/stay/img/people/%EC%86%A1%EC%83%88%EB%B2%BD.png\", \"imageCredit\": \"사진 틈새TV · CC BY 3.0\"}, {\"name\": \"장신영\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%A5%EC%8B%A0%EC%98%81\"}, \"years\": \"1984–\", \"imageUrl\": \"/s/stay/img/people/%EC%9E%A5%EC%8B%A0%EC%98%81.jpg\", \"imageCredit\": \"사진 acrofan.com · CC BY-SA 3.0\"}, {\"name\": \"진희경\", \"role\": \"배우\", \"oneLine\": \"익산에서 태어나 군산영광여고를 나온 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A7%84%ED%9D%AC%EA%B2%BD\"}, \"years\": \"1968–\", \"imageUrl\": \"/assets/mirror/3dc384187a900e95.png\"}, {\"name\": \"이서원\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%84%9C%EC%9B%90\"}, \"years\": \"1997–\", \"imageUrl\": \"/s/stay/img/people/%EC%9D%B4%EC%84%9C%EC%9B%90.png\", \"imageCredit\": \"사진 KBS 뷰 · CC BY 3.0\"}, {\"name\": \"병헌\", \"role\": \"배우\", \"oneLine\": \"그룹 틴탑 활동을 거쳐 배우로 옮겼습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B3%91%ED%97%8C\"}, \"years\": \"1993–\", \"imageUrl\": \"/s/stay/img/people/%EB%B3%91%ED%97%8C.jpg\", \"imageCredit\": \"사진 뉴스인스타 · CC BY 3.0\"}, {\"name\": \"정혁\", \"role\": \"모델\", \"oneLine\": \"2015년 서울패션위크로 데뷔한 모델 겸 방송인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%ED%98%81_%28%EB%AA%A8%EB%8D%B8%29\"}, \"years\": \"1991–\", \"imageUrl\": \"/s/stay/img/people/%EC%A0%95%ED%98%81.jpg\", \"imageCredit\": \"사진 파리게이츠 · CC BY 3.0\"}, {\"name\": \"박화요비\", \"role\": \"가수\", \"oneLine\": \"R&B 를 주로 부르는 가수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%ED%99%94%EC%9A%94%EB%B9%84\"}, \"years\": \"1982–\", \"imageUrl\": \"/s/stay/img/people/%EB%B0%95%ED%99%94%EC%9A%94%EB%B9%84.jpg\", \"imageCredit\": \"사진 Jinho.Jung · CC BY-SA 2.0\"}, {\"name\": \"고건\", \"role\": \"정치인\", \"oneLine\": \"제30·35대 국무총리를 지냈습니다. 아버지는 철학자 고형곤입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%EA%B1%B4\"}, \"years\": \"1938–\", \"imageUrl\": \"/s/stay/img/people/%EA%B3%A0%EA%B1%B4.jpg\", \"imageCredit\": \"사진 서울특별시 소방재난본부 · CC BY-SA 4.0\"}, {\"name\": \"김관영\", \"role\": \"정치인\", \"oneLine\": \"전북특별자치도지사입니다. 회계사·변호사를 거쳤습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EA%B4%80%EC%98%81\"}, \"years\": \"1969–\", \"imageUrl\": \"/s/stay/img/people/%EA%B9%80%EA%B4%80%EC%98%81.jpg\", \"imageCredit\": \"사진 대한민국 국회 의장비서실 · KOGL Type 1\"}, {\"name\": \"함운경\", \"role\": \"정치인\", \"oneLine\": \"1980년대 학생운동가 출신입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%A8%EC%9A%B4%EA%B2%BD\"}, \"years\": \"1964–\", \"imageUrl\": \"/s/stay/img/people/%ED%95%A8%EC%9A%B4%EA%B2%BD.jpg\", \"imageCredit\": \"사진 Teakim11 · CC BY-SA 4.0\"}, {\"name\": \"차우찬\", \"role\": \"야구 선수\", \"oneLine\": \"투수로 뛰었고 지금은 야구 해설위원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B0%A8%EC%9A%B0%EC%B0%AC\"}, \"years\": \"1987–\", \"imageUrl\": \"/s/stay/img/people/%EC%B0%A8%EC%9A%B0%EC%B0%AC.jpg\", \"imageCredit\": \"사진 원래 올린이는 한국어 위키백과의 Realidad y Illusion · CC BY-SA 2.5\"}, {\"name\": \"이진영\", \"role\": \"야구 선수\", \"oneLine\": \"외야수로 뛰었고 지금은 코치입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A7%84%EC%98%81_%281980%EB%85%84%29\"}, \"years\": \"1980–\", \"imageUrl\": \"/s/stay/img/people/%EC%9D%B4%EC%A7%84%EC%98%81.jpg\", \"imageCredit\": \"사진 itsmygabble · CC BY 4.0\"}, {\"name\": \"오지환\", \"role\": \"야구 선수\", \"oneLine\": \"LG 트윈스 내야수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%98%A4%EC%A7%80%ED%99%98\"}, \"years\": \"1990–\", \"imageUrl\": \"/s/stay/img/people/%EC%98%A4%EC%A7%80%ED%99%98.png\", \"imageCredit\": \"사진 OSEN SPORTS · CC BY-SA 3.0\"}, {\"name\": \"최서우\", \"role\": \"스키점프 선수\", \"oneLine\": \"1998년부터 국가대표로 뛴 스키점프 선수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B5%9C%EC%84%9C%EC%9A%B0\"}, \"years\": \"1982–\", \"imageUrl\": \"/s/stay/img/people/%EC%B5%9C%EC%84%9C%EC%9A%B0.jpg\", \"imageCredit\": \"사진 Clément Bucco-Lechat · CC BY-SA 3.0\"}, {\"name\": \"문호준\", \"role\": \"프로게이머\", \"oneLine\": \"카트라이더 리그 최다 우승자입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%AC%B8%ED%98%B8%EC%A4%80\"}, \"years\": \"1997–\", \"imageUrl\": \"/s/stay/img/people/%EB%AC%B8%ED%98%B8%EC%A4%80.jpg\", \"imageCredit\": \"사진 acrofan.com · CC BY-SA 3.0\"}, {\"name\": \"이병훈\", \"role\": \"시인\", \"oneLine\": \"옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EB%B3%91%ED%9B%88_%28%EC%8B%9C%EC%9D%B8%29\"}, \"years\": \"1925–2009\"}, {\"name\": \"심호택\", \"role\": \"시인\", \"oneLine\": \"민중의 삶을 시로 옮긴 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%8B%AC%ED%98%B8%ED%83%9D\"}, \"years\": \"1947–2010\"}, {\"name\": \"문효치\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 1966년 신춘문예로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%AC%B8%ED%9A%A8%EC%B9%98\"}, \"years\": \"1943–\"}, {\"name\": \"이연주\", \"role\": \"시인\", \"oneLine\": \"짧게 활동하고 떠난 시인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%97%B0%EC%A3%BC_%28%EC%8B%9C%EC%9D%B8%29\"}, \"years\": \"1953–1992\"}, {\"name\": \"주하림\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어나 2009년 창비신인시인상으로 등단했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A3%BC%ED%95%98%EB%A6%BC\"}}, {\"name\": \"고형곤\", \"role\": \"철학자\", \"oneLine\": \"선(禪) 연구로 알려진 철학자이자 정치가입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B3%A0%ED%98%95%EA%B3%A4\"}, \"years\": \"1906–2004\"}, {\"name\": \"반석평\", \"role\": \"조선 문신\", \"oneLine\": \"노비 출신으로 문과에 급제해 판서에 오른 조선 중종 때 문신입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%98%EC%84%9D%ED%8F%89\"}, \"years\": \"1472–1540\"}, {\"name\": \"장재호\", \"role\": \"배우\", \"oneLine\": \"군산 출신 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%A5%EC%9E%AC%ED%98%B8_%28%EB%B0%B0%EC%9A%B0%29\"}, \"years\": \"1986–\"}, {\"name\": \"강민지\", \"role\": \"배우 · 모델\", \"oneLine\": \"모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B0%95%EB%AF%BC%EC%A7%80_%28%EB%B0%B0%EC%9A%B0%29\"}, \"years\": \"1996–\"}, {\"name\": \"김혜선\", \"role\": \"희극인\", \"oneLine\": \"군산 출신 희극 배우입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%ED%98%9C%EC%84%A0_%28%ED%9D%AC%EA%B7%B9%EC%9D%B8%29\"}, \"years\": \"1983–\"}, {\"name\": \"이옥주\", \"role\": \"희극인\", \"oneLine\": \"군산중앙여고를 나와 1988년 MBC 개그콘테스트 대상을 받았습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%98%A5%EC%A3%BC_%28%ED%9D%AC%EA%B7%B9%EC%9D%B8%29\"}, \"years\": \"1969–\"}, {\"name\": \"박윤경\", \"role\": \"가수\", \"oneLine\": \"군산 출신 가수 겸 기업인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EC%9C%A4%EA%B2%BD_%28%EA%B0%80%EC%88%98%29\"}, \"years\": \"1970–\"}, {\"name\": \"하준\", \"role\": \"가수\", \"oneLine\": \"군산 출신 가수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%98%EC%A4%80_%28%EA%B0%80%EC%88%98%29\"}, \"years\": \"1965–\"}, {\"name\": \"강봉균\", \"role\": \"정치인\", \"oneLine\": \"재정경제부 장관과 3선 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B0%95%EB%B4%89%EA%B7%A0_%28%EC%A0%95%EC%B9%98%EC%9D%B8%29\"}, \"years\": \"1943–2017\"}, {\"name\": \"양일동\", \"role\": \"정치인\", \"oneLine\": \"옥구군 서수면 출신으로 야당 정치인으로 활동했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%96%91%EC%9D%BC%EB%8F%99\"}, \"years\": \"1912–1980\"}, {\"name\": \"채영석\", \"role\": \"정치인\", \"oneLine\": \"군산에서 3선 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EC%98%81%EC%84%9D\"}, \"years\": \"1934–2006\"}, {\"name\": \"신영대\", \"role\": \"정치인\", \"oneLine\": \"군산 지역구 국회의원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%8B%A0%EC%98%81%EB%8C%80\"}, \"years\": \"1968–\"}, {\"name\": \"김의겸\", \"role\": \"언론인 · 정치인\", \"oneLine\": \"한겨레 기자를 거쳐 청와대 대변인과 국회의원을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EC%9D%98%EA%B2%B8\"}, \"years\": \"1963–\"}, {\"name\": \"임병찬\", \"role\": \"의병장\", \"oneLine\": \"최익현과 함께 거병한 구한말 의병장으로, 단식 끝에 순국했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9E%84%EB%B3%91%EC%B0%AC\"}, \"years\": \"1851–1916\"}, {\"name\": \"이수현\", \"role\": \"독립운동가\", \"oneLine\": \"호는 산남. 군산 출신 독립운동가입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%88%98%ED%98%84_%281895%EB%85%84%29\"}, \"years\": \"1895–1980\"}, {\"name\": \"전진\", \"role\": \"비전향 장기수\", \"oneLine\": \"군산 출신으로 오래 수감 생활을 했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%84%EC%A7%84_%281923%EB%85%84%29\"}, \"years\": \"1923–2012\"}, {\"name\": \"이길여\", \"role\": \"의료인 · 교육자\", \"oneLine\": \"가천대학교 길병원을 세우고 가천대 총장을 맡고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EA%B8%B8%EC%97%AC\"}, \"years\": \"1932–\"}, {\"name\": \"최길선\", \"role\": \"기업인\", \"oneLine\": \"현대중공업 회장을 지낸 조선업 경영인입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B5%9C%EA%B8%B8%EC%84%A0\"}, \"years\": \"1946–\"}, {\"name\": \"은성수\", \"role\": \"공무원\", \"oneLine\": \"제7대 금융위원회 위원장을 지냈습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%80%EC%84%B1%EC%88%98_%28%EA%B3%B5%EB%AC%B4%EC%9B%90%29\"}, \"years\": \"1961–\"}, {\"name\": \"채금석\", \"role\": \"축구 선수\", \"oneLine\": \"일제강점기 경성 축구단 선수로 경평축구대항전에 나섰습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%B1%84%EA%B8%88%EC%84%9D\"}, \"years\": \"1904–1995\"}, {\"name\": \"박경완\", \"role\": \"야구 선수\", \"oneLine\": \"SK 와이번스 포수로 한국 야구의 대표적인 포수였습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EA%B2%BD%EC%99%84\"}, \"years\": \"1972–\"}, {\"name\": \"조계현\", \"role\": \"야구 선수\", \"oneLine\": \"해태 타이거즈 투수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A1%B0%EA%B3%84%ED%98%84\"}, \"years\": \"1964–\"}, {\"name\": \"정명원\", \"role\": \"야구 선수\", \"oneLine\": \"현대 유니콘스 투수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%EB%AA%85%EC%9B%90\"}, \"years\": \"1966–\"}, {\"name\": \"조규제\", \"role\": \"야구 선수\", \"oneLine\": \"왼손 투수로 뛰고 지금은 투수코치입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A1%B0%EA%B7%9C%EC%A0%9C\"}, \"years\": \"1967–\"}, {\"name\": \"이천웅\", \"role\": \"야구 선수\", \"oneLine\": \"LG 트윈스 외야수로 뛰었습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%B2%9C%EC%9B%85\"}, \"years\": \"1988–\"}, {\"name\": \"노상래\", \"role\": \"축구 선수\", \"oneLine\": \"공격수로 뛰었고 지도자로 일하고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%85%B8%EC%83%81%EB%9E%98\"}, \"years\": \"1970–\"}, {\"name\": \"노수진\", \"role\": \"축구 선수\", \"oneLine\": \"공격수로 뛰었고 지금은 체육 교사입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%85%B8%EC%88%98%EC%A7%84\"}, \"years\": \"1962–\"}, {\"name\": \"유동우\", \"role\": \"축구 선수\", \"oneLine\": \"수비수로 뛰었고 대학 축구부 감독을 맡고 있습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9C%A0%EB%8F%99%EC%9A%B0\"}, \"years\": \"1968–\"}, {\"name\": \"이정효\", \"role\": \"축구 감독\", \"oneLine\": \"풀백으로 뛰었고 지금은 프로팀 감독입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A0%95%ED%9A%A8\"}, \"years\": \"1975–\"}, {\"name\": \"박성현\", \"role\": \"양궁 선수\", \"oneLine\": \"군산 소룡초에서 활을 처음 잡아 올림픽 금메달을 땄습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EB%B0%95%EC%84%B1%ED%98%84_%28%EC%96%91%EA%B6%81_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1983–\"}, {\"name\": \"김광선\", \"role\": \"권투 선수\", \"oneLine\": \"1988년 서울 올림픽 복싱 플라이급 금메달리스트입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EA%B9%80%EA%B4%91%EC%84%A0_%28%EA%B6%8C%ED%88%AC_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1964–\"}, {\"name\": \"정경미\", \"role\": \"유도 선수\", \"oneLine\": \"군산 출신 유도 선수입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%95%EA%B2%BD%EB%AF%B8_%28%EC%9C%A0%EB%8F%84_%EC%84%A0%EC%88%98%29\"}, \"years\": \"1985–\"}, {\"name\": \"전미라\", \"role\": \"테니스 선수\", \"oneLine\": \"테니스 선수로 뛰었고 지금은 해설위원입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%EC%A0%84%EB%AF%B8%EB%9D%BC\"}, \"years\": \"1978–\"}, {\"name\": \"한왕용\", \"role\": \"산악인\", \"oneLine\": \"히말라야 8000m 14좌를 세계 11번째로 완등했습니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"한국어 위키백과\", \"url\": \"https://ko.wikipedia.org/wiki/%ED%95%9C%EC%99%95%EC%9A%A9\"}, \"years\": \"1966–\"}]}"}, {"id": "chronicle", "name": "시간의 골목", "enabled": false, "locked": false, "data": "{\"kind\":\"chronicle\",\"version\":1,\"items\":[{\"year\":1380,\"title\":\"진포대첩\",\"summary\":\"최무선의 화포가 왜구 함대를 무너뜨린 해. 군산 해양사의 출발점으로 꼽힙니다.\",\"place\":\"진포시비공원\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/9b073d4a1b5fb62c.jpg\"},{\"year\":1899,\"title\":\"군산 개항\",\"summary\":\"항구가 열리며 도시의 성격이 한 번에 바뀝니다.\",\"place\":\"군산 내항\",\"turning\":true,\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\"},{\"year\":1908,\"title\":\"구 군산세관 본관 준공\",\"summary\":\"지금도 서 있는 벽돌 건물입니다. 개항기 행정의 자리였습니다.\",\"place\":\"구 군산세관 본관\",\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/22af0bff45e5fce8.jpg\"},{\"year\":1926,\"title\":\"내항 부잔교 축조\",\"summary\":\"조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로였습니다.\",\"place\":\"군산 내항 부잔교\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\"},{\"year\":1937,\"title\":\"『탁류』 연재 시작\",\"summary\":\"채만식이 이 도시를 소설의 무대로 세웁니다.\",\"place\":\"채만식문학관\",\"verified\":\"확인\",\"source\":{\"name\":\"군산시 채만식문학관\",\"url\":\"https://www.gunsan.go.kr/chae/\"},\"imageUrl\":\"/assets/mirror/8043bad52d95a84b.jpg\"},{\"year\":1990,\"title\":\"금강하굿둑 준공\",\"summary\":\"강과 바다의 경계가 사람 손으로 그어집니다.\",\"place\":\"금강하굿둑\",\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"imageUrl\":\"/assets/mirror/aaf32d2c8e5faffb.jpg\"},{\"year\":2010,\"title\":\"새만금 방조제 개통\",\"summary\":\"간척으로 해안선이 다시 그려집니다.\",\"place\":\"새만금 방조제\",\"turning\":true,\"verified\":\"확인필요\",\"source\":{\"name\":\"대한민국 구석구석\",\"url\":\"https://korean.visitkorea.or.kr/\"},\"imageUrl\":\"/assets/mirror/487643cdcfd19fdb.jpg\"}],\"title\":\"시간의 골목\",\"subtitle\":\"이 도시를 만든 해들\"}"}, {"id": "postcard", "name": "오늘의 엽서", "enabled": false, "locked": false, "data": "{\"kind\": \"postcard\", \"version\": 1, \"items\": []}"}, {"id": "quiz", "name": "뒤집어 보는 질문", "enabled": false, "locked": false, "data": "{\"kind\": \"quiz\", \"version\": 1, \"items\": [{\"question\": \"부잔교는 왜 물 위에 뜨도록 만들었을까?\", \"hint\": \"서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.\", \"topic\": \"군산 내항\", \"level\": \"초등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"『탁류』의 “탁류”는 무엇을 가리키는 말일까?\", \"hint\": \"금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.\", \"topic\": \"채만식문학관\", \"level\": \"중등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"question\": \"해망굴은 무엇을 위해 뚫린 굴일까?\", \"hint\": \"근대 교통로였고, 전쟁기에는 다른 쓰임도 있었습니다. 굴 하나에 두 시대가 겹칩니다.\", \"topic\": \"해망굴\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"고군산군도의 ‘고(古)’ 는 왜 붙었을까?\", \"hint\": \"지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.\", \"topic\": \"고군산군도\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"뒤집어 보는 질문\", \"subtitle\": \"뒤집으면 힌트가 나옵니다\"}"}, {"id": "faq", "name": "자주 묻는 질문", "enabled": true, "locked": false}, {"id": "rules", "name": "이용 규정", "enabled": true, "locked": false}, {"id": "weather", "name": "날씨", "enabled": true, "locked": false}, {"id": "planner", "name": "계절별 추천 하루", "enabled": false, "locked": false, "data": "{\"kind\":\"planner\",\"version\":1,\"title\":\"계절별 추천 하루\",\"subtitle\":\"지금 계절에 맞는 코스만 보여 드립니다\",\"items\":[{\"name\":\"탁류길 코스\",\"audience\":\"걷기 좋아하는 손님\",\"why\":\"소설 『탁류』가 지나간 자리를 그대로 따라 걷습니다.\",\"startTime\":\"10:00\",\"stops\":[{\"name\":\"채만식문학관\",\"minutes\":12,\"note\":\"금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.\",\"searchQuery\":\"군산 채만식문학관\",\"imageUrl\":\"/assets/mirror/8043bad52d95a84b.jpg\",\"latitude\":36.0085981064,\"longitude\":126.7578853293},{\"name\":\"군산 내항 부잔교\",\"minutes\":9,\"note\":\"조수에 오르내리던 뜬다리. 이 코스의 중심입니다.\",\"searchQuery\":\"군산 내항 부잔교\",\"imageUrl\":\"/assets/mirror/1f35ef26139c6cb9.jpg\",\"latitude\":35.9892997236,\"longitude\":126.7174508158},{\"name\":\"째보선창\",\"minutes\":7,\"note\":\"항구 노동의 기억이 남은 선창입니다.\",\"searchQuery\":\"군산 째보선창\",\"latitude\":35.9875345,\"longitude\":126.7199875},{\"name\":\"초원사진관\",\"minutes\":11,\"note\":\"영화가 남기고 간 자리입니다.\",\"searchQuery\":\"군산 초원사진관\",\"imageUrl\":\"/assets/mirror/d5433fe48de3da05.jpg\",\"latitude\":35.9877369536,\"longitude\":126.7083826065},{\"name\":\"이성당\",\"minutes\":6,\"note\":\"코스의 끝. 단팥빵 하나로 반나절을 닫습니다.\",\"searchQuery\":\"군산 이성당\",\"imageUrl\":\"/assets/mirror/37c90494d28478e2.png\",\"latitude\":35.9870443303,\"longitude\":126.7111681428}],\"verified\":\"확인\",\"source\":{\"name\":\"군산 스탬프투어\",\"url\":\"https://www.gunsanstamp.kr/\"},\"season\":\"가을\",\"rank\":2},{\"name\":\"아리랑길 코스\",\"audience\":\"걷기 좋아하는 손님\",\"why\":\"개항기 건물만 골라 짧게 도는 길입니다.\",\"startTime\":\"10:00\",\"stops\":[{\"name\":\"구 군산세관 본관\",\"minutes\":8,\"note\":\"1908년에 지어진 벽돌 건물입니다.\",\"searchQuery\":\"구 군산세관 본관\",\"imageUrl\":\"/assets/mirror/22af0bff45e5fce8.jpg\",\"latitude\":35.9915087952,\"longitude\":126.7113016917},{\"name\":\"군산근대미술관\",\"minutes\":5,\"note\":\"옛 은행 금고가 그대로 남아 있습니다.\",\"searchQuery\":\"군산근대미술관\",\"imageUrl\":\"/assets/mirror/d8764592a03b0103.jpg\",\"latitude\":35.9899367776,\"longitude\":126.7127710457},{\"name\":\"해망굴\",\"minutes\":14,\"note\":\"굴 하나에 두 시대가 겹칩니다.\",\"searchQuery\":\"군산 해망굴\",\"imageUrl\":\"/assets/mirror/0a1e0ff50ca7ee37.jpg\",\"latitude\":35.9913180812,\"longitude\":126.7048496204}],\"verified\":\"확인필요\",\"source\":{\"name\":\"군산 스탬프투어\",\"url\":\"https://www.gunsanstamp.kr/\"},\"season\":\"봄\",\"rank\":1},{\"name\":\"개항장 골목 한 바퀴\",\"audience\":\"처음 온 손님\",\"why\":\"해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.\",\"startTime\":\"09:30\",\"stops\":[{\"name\":\"군산근대역사박물관\",\"minutes\":90,\"moveMinutes\":12,\"note\":\"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\"searchQuery\":\"군산근대역사박물관\",\"imageUrl\":\"/assets/mirror/af673cc75c0ed696.jpg\",\"latitude\":35.9908197098,\"longitude\":126.7121231556},{\"name\":\"초원사진관\",\"minutes\":30,\"moveMinutes\":10,\"note\":\"영화가 남기고 간 자리입니다.\",\"searchQuery\":\"군산 초원사진관\",\"imageUrl\":\"/assets/mirror/d5433fe48de3da05.jpg\",\"latitude\":35.9877369536,\"longitude\":126.7083826065},{\"name\":\"이성당\",\"minutes\":40,\"moveMinutes\":8,\"note\":\"단팥빵 하나로 오후를 엽니다.\",\"searchQuery\":\"군산 이성당\",\"imageUrl\":\"/assets/mirror/37c90494d28478e2.png\",\"latitude\":35.9870443303,\"longitude\":126.7111681428},{\"name\":\"경암동 철길마을\",\"minutes\":60,\"moveMinutes\":18,\"note\":\"해 질 무렵 빛이 가장 좋습니다.\",\"searchQuery\":\"군산 경암동 철길마을\",\"imageUrl\":\"/assets/mirror/6d59722cedddd566.jpg\",\"latitude\":35.9813520474,\"longitude\":126.7362330582}],\"verified\":\"확인\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"season\":\"가을\",\"rank\":1},{\"name\":\"비 오면 여기로\",\"audience\":\"장마철에 온 손님\",\"why\":\"실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.\",\"season\":\"여름\",\"startTime\":\"10:30\",\"stops\":[{\"name\":\"군산근대건축관\",\"minutes\":60,\"moveMinutes\":10,\"note\":\"옛 은행 건물 안이 통째로 전시입니다.\",\"searchQuery\":\"군산근대건축관\",\"imageUrl\":\"/assets/mirror/5000f0a4feb45788.jpg\",\"latitude\":35.9894201935,\"longitude\":126.7141598237},{\"name\":\"한일옥\",\"minutes\":60,\"moveMinutes\":8,\"note\":\"무국 한 그릇으로 몸을 데웁니다.\",\"searchQuery\":\"군산 한일옥\",\"latitude\":35.9874081251,\"longitude\":126.7082350808},{\"name\":\"째보선창 근처 카페\",\"minutes\":90,\"moveMinutes\":10,\"note\":\"비 그칠 때까지 앉아 있기 좋습니다.\",\"searchQuery\":\"군산 째보선창 카페\",\"latitude\":35.9875345,\"longitude\":126.7199875}],\"verified\":\"확인필요\",\"source\":{\"name\":\"군산시 문화관광\",\"url\":\"https://www.gunsan.go.kr/tour/\"},\"rank\":1}]}"}], "look": {"radius": "0px", "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)", "texture": "repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)", "fontBody": "'Gowun Batang', 'Noto Serif KR', serif", "borderWidth": "2px", "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif", "sectionSpace": "4rem", "headingWeight": "400", "headingTracking": "0em"}}} \ No newline at end of file diff --git a/solution/site/scripts/mockup/vendor/embla-carousel.umd.js b/solution/site/scripts/mockup/vendor/embla-carousel.umd.js new file mode 100644 index 0000000..5832ad5 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/embla-carousel.umd.js @@ -0,0 +1 @@ +!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(n="undefined"!=typeof globalThis?globalThis:n||self).EmblaCarousel=t()}(this,(function(){"use strict";function n(n){return"number"==typeof n}function t(n){return"string"==typeof n}function e(n){return"boolean"==typeof n}function r(n){return"[object Object]"===Object.prototype.toString.call(n)}function o(n){return Math.abs(n)}function i(n){return Math.sign(n)}function c(n,t){return o(n-t)}function u(n){return f(n).map(Number)}function s(n){return n[a(n)]}function a(n){return Math.max(0,n.length-1)}function d(n,t){return t===a(n)}function l(n,t=0){return Array.from(Array(n),((n,e)=>t+e))}function f(n){return Object.keys(n)}function p(n,t){return[n,t].reduce(((n,t)=>(f(t).forEach((e=>{const o=n[e],i=t[e],c=r(o)&&r(i);n[e]=c?p(o,i):i})),n)),{})}function m(n,t){return void 0!==t.MouseEvent&&n instanceof t.MouseEvent}function g(){let n=[];const t={add:function(e,r,o,i={passive:!0}){let c;if("addEventListener"in e)e.addEventListener(r,o,i),c=()=>e.removeEventListener(r,o,i);else{const n=e;n.addListener(o),c=()=>n.removeListener(o)}return n.push(c),t},clear:function(){n=n.filter((n=>n()))}};return t}function h(n,t,e,r){const o=g(),i=1e3/60;let c=null,u=0,s=0;function a(n){if(!s)return;c||(c=n,e(),e());const o=n-c;for(c=n,u+=o;u>=i;)e(),u-=i;r(u/i),s&&(s=t.requestAnimationFrame(a))}function d(){t.cancelAnimationFrame(s),c=null,u=0,s=0}return{init:function(){o.add(n,"visibilitychange",(()=>{n.hidden&&(c=null,u=0)}))},destroy:function(){d(),o.clear()},start:function(){s||(s=t.requestAnimationFrame(a))},stop:d,update:e,render:r}}function x(n=0,t=0){const e=o(n-t);function r(t){return t<n}function i(n){return n>t}function c(n){return r(n)||i(n)}return{length:e,max:t,min:n,constrain:function(e){return c(e)?r(e)?n:t:e},reachedAny:c,reachedMax:i,reachedMin:r,removeOffset:function(n){return e?n-e*Math.ceil((n-t)/e):n}}}function y(n,t,e){const{constrain:r}=x(0,n),i=n+1;let c=u(t);function u(n){return e?o((i+n)%i):r(n)}function s(){return c}function a(){return y(n,s(),e)}const d={get:s,set:function(n){return c=u(n),d},add:function(n){return a().set(s()+n)},clone:a};return d}function v(n,t,r,u,s,a,d,l,f,p,h,y,v,b,S,w,E,L,D){const{cross:I,direction:M}=n,A=["INPUT","SELECT","TEXTAREA"],F={passive:!1},T=g(),O=g(),P=x(50,225).constrain(b.measure(20)),z={mouse:300,touch:400},H={mouse:500,touch:600},k=S?43:25;let V=!1,B=0,C=0,N=!1,R=!1,j=!1,G=!1;function q(n){if(!m(n,u)&&n.touches.length>=2)return U(n);const t=a.readPoint(n),e=a.readPoint(n,I),r=c(t,B),o=c(e,C);if(!R&&!G){if(!n.cancelable)return U(n);if(R=r>o,!R)return U(n)}const i=a.pointerMove(n);r>w&&(j=!0),p.useFriction(.3).useDuration(.75),l.start(),s.add(M(i)),n.preventDefault()}function U(n){const t=h.byDistance(0,!1).index!==y.get(),e=a.pointerUp(n)*(S?H:z)[G?"mouse":"touch"],r=function(n,t){const e=y.add(-1*i(n)),r=h.byDistance(n,!S).distance;return S||o(n)<P?r:E&&t?.5*r:h.byIndex(e.get(),0).distance}(M(e),t),u=function(n,t){if(0===n||0===t)return 0;if(o(n)<=o(t))return 0;const e=c(o(n),o(t));return o(e/n)}(e,r),s=k-10*u,d=L+u/50;R=!1,N=!1,O.clear(),p.useDuration(s).useFriction(d),f.distance(r,!S),G=!1,v.emit("pointerUp")}function W(n){j&&(n.stopPropagation(),n.preventDefault(),j=!1)}return{init:function(n){if(!D)return;function o(o){(e(D)||D(n,o))&&function(n){const e=m(n,u);if(G=e,j=S&&e&&!n.buttons&&V,V=c(s.get(),d.get())>=2,e&&0!==n.button)return;if(function(n){const t=n.nodeName||"";return A.includes(t)}(n.target))return;N=!0,a.pointerDown(n),p.useFriction(0).useDuration(0),s.set(d),function(){const n=G?r:t;O.add(n,"touchmove",q,F).add(n,"touchend",U).add(n,"mousemove",q,F).add(n,"mouseup",U)}(),B=a.readPoint(n),C=a.readPoint(n,I),v.emit("pointerDown")}(o)}const i=t;T.add(i,"dragstart",(n=>n.preventDefault()),F).add(i,"touchmove",(()=>{}),F).add(i,"touchend",(()=>{})).add(i,"touchstart",o).add(i,"mousedown",o).add(i,"touchcancel",U).add(i,"contextmenu",U).add(i,"click",W,!0)},destroy:function(){T.clear(),O.clear()},pointerDown:function(){return N}}}function b(n,t){let e,r;function i(n){return n.timeStamp}function c(e,r){const o="client"+("x"===(r||n.scroll)?"X":"Y");return(m(e,t)?e:e.touches[0])[o]}return{pointerDown:function(n){return e=n,r=n,c(n)},pointerMove:function(n){const t=c(n)-c(r),o=i(n)-i(e)>170;return r=n,o&&(e=n),t},pointerUp:function(n){if(!e||!r)return 0;const t=c(r)-c(e),u=i(n)-i(e),s=i(n)-i(r)>170,a=t/u;return u&&!s&&o(a)>.1?a:0},readPoint:c}}function S(n,t,r,i,c,u,s){const a=[n].concat(i);let d,l,f=[],p=!1;function m(n){return c.measureSize(s.measure(n))}return{init:function(c){u&&(l=m(n),f=i.map(m),d=new ResizeObserver((r=>{(e(u)||u(c,r))&&function(e){for(const r of e){if(p)return;const e=r.target===n,u=i.indexOf(r.target),s=e?l:f[u];if(o(m(e?n:i[u])-s)>=.5){c.reInit(),t.emit("resize");break}}}(r)})),r.requestAnimationFrame((()=>{a.forEach((n=>d.observe(n)))})))},destroy:function(){p=!0,d&&d.disconnect()}}}function w(n,t,e,r,i){const c=i.measure(10),u=i.measure(50),s=x(.1,.99);let a=!1;function d(){return!a&&(!!n.reachedAny(e.get())&&!!n.reachedAny(t.get()))}return{shouldConstrain:d,constrain:function(i){if(!d())return;const a=n.reachedMin(t.get())?"min":"max",l=o(n[a]-t.get()),f=e.get()-t.get(),p=s.constrain(l/u);e.subtract(f*p),!i&&o(f)<c&&(e.set(n.constrain(e.get())),r.useDuration(25).useBaseFriction())},toggleActive:function(n){a=!n}}}function E(n,t,e,r){const o=t.min+.1,i=t.max+.1,{reachedMin:c,reachedMax:u}=x(o,i);return{loop:function(t){if(!function(n){return 1===n?u(e.get()):-1===n&&c(e.get())}(t))return;const o=n*(-1*t);r.forEach((n=>n.add(o)))}}}function L(n,t,e,r,c){const{reachedAny:u,removeOffset:a,constrain:d}=r;function l(n){return n.concat().sort(((n,t)=>o(n)-o(t)))[0]}function f(t,r){const o=[t,t+e,t-e];if(!n)return t;if(!r)return l(o);const c=o.filter((n=>i(n)===r));return c.length?l(c):s(o)-e}return{byDistance:function(e,r){const i=c.get()+e,{index:s,distance:l}=function(e){const r=n?a(e):d(e),i=t.map(((n,t)=>({diff:f(n-r,0),index:t}))).sort(((n,t)=>o(n.diff)-o(t.diff))),{index:c}=i[0];return{index:c,distance:r}}(i),p=!n&&u(i);return!r||p?{index:s,distance:e}:{index:s,distance:e+f(t[s]-l,0)}},byIndex:function(n,e){return{index:n,distance:f(t[n]-c.get(),e)}},shortcut:f}}function D(t,r,o,i,c,u,s,a){const d={passive:!0,capture:!0};let l=0;function f(n){"Tab"===n.code&&(l=(new Date).getTime())}return{init:function(p){a&&(u.add(document,"keydown",f,!1),r.forEach(((r,f)=>{u.add(r,"focus",(r=>{(e(a)||a(p,r))&&function(e){if((new Date).getTime()-l>10)return;s.emit("slideFocusStart"),t.scrollLeft=0;const r=o.findIndex((n=>n.includes(e)));n(r)&&(c.useDuration(0),i.index(r,0),s.emit("slideFocus"))}(f)}),d)})))}}}function I(t){let e=t;function r(t){return n(t)?t:t.get()}return{get:function(){return e},set:function(n){e=r(n)},add:function(n){e+=r(n)},subtract:function(n){e-=r(n)}}}function M(n,t){const e="x"===n.scroll?function(n){return`translate3d(${n}px,0px,0px)`}:function(n){return`translate3d(0px,${n}px,0px)`},r=t.style;let o=null,i=!1;return{clear:function(){i||(r.transform="",t.getAttribute("style")||t.removeAttribute("style"))},to:function(t){if(i)return;const c=(u=n.direction(t),Math.round(100*u)/100);var u;c!==o&&(r.transform=e(c),o=c)},toggleActive:function(n){i=!n}}}function A(n,t,e,r,o,i,c,s,a){const d=.5,l=u(o),f=u(o).reverse(),p=function(){const n=c[0];return h(g(f,n),e,!1)}().concat(function(){const n=t-c[0]-1;return h(g(l,n),-e,!0)}());function m(n,t){return n.reduce(((n,t)=>n-o[t]),t)}function g(n,t){return n.reduce(((n,e)=>m(n,t)>0?n.concat([e]):n),[])}function h(o,c,u){const l=function(n){return i.map(((e,o)=>({start:e-r[o]+d+n,end:e+t-d+n})))}(c);return o.map((t=>{const r=u?0:-e,o=u?e:0,i=u?"end":"start",c=l[t][i];return{index:t,loopPoint:c,slideLocation:I(-1),translate:M(n,a[t]),target:()=>s.get()>c?r:o}}))}return{canLoop:function(){return p.every((({index:n})=>m(l.filter((t=>t!==n)),t)<=.1))},clear:function(){p.forEach((n=>n.translate.clear()))},loop:function(){p.forEach((n=>{const{target:t,translate:e,slideLocation:r}=n,o=t();o!==r.get()&&(e.to(o),r.set(o))}))},loopPoints:p}}function F(n,t,r){let o,i=!1;return{init:function(c){r&&(o=new MutationObserver((n=>{i||(e(r)||r(c,n))&&function(n){for(const e of n)if("childList"===e.type){c.reInit(),t.emit("slidesChanged");break}}(n)})),o.observe(n,{childList:!0}))},destroy:function(){o&&o.disconnect(),i=!0}}}function T(n,t,e,r){const o={};let i,c=null,u=null,s=!1;return{init:function(){i=new IntersectionObserver((n=>{s||(n.forEach((n=>{const e=t.indexOf(n.target);o[e]=n})),c=null,u=null,e.emit("slidesInView"))}),{root:n.parentElement,threshold:r}),t.forEach((n=>i.observe(n)))},destroy:function(){i&&i.disconnect(),s=!0},get:function(n=!0){if(n&&c)return c;if(!n&&u)return u;const t=function(n){return f(o).reduce(((t,e)=>{const r=parseInt(e),{isIntersecting:i}=o[r];return(n&&i||!n&&!i)&&t.push(r),t}),[])}(n);return n&&(c=t),n||(u=t),t}}}function O(t,e,r,i,c,d,l,f,p){const{startEdge:m,endEdge:g,direction:h}=t,x=n(r);return{groupSlides:function(n){return x?function(n,t){return u(n).filter((n=>n%t==0)).map((e=>n.slice(e,e+t)))}(n,r):function(n){return n.length?u(n).reduce(((t,r,u)=>{const x=s(t)||0,y=0===x,v=r===a(n),b=c[m]-d[x][m],S=c[m]-d[r][g],w=!i&&y?h(l):0,E=o(S-(!i&&v?h(f):0)-(b+w));return u&&E>e+p&&t.push(r),v&&t.push(n.length),t}),[]).map(((t,e,r)=>{const o=Math.max(r[e-1]||0);return n.slice(o,t)})):[]}(n)}}}function P(n,e,r,f,p,m,P){const{align:z,axis:H,direction:k,startIndex:V,loop:B,duration:C,dragFree:N,dragThreshold:R,inViewThreshold:j,slidesToScroll:G,skipSnaps:q,containScroll:U,watchResize:W,watchSlides:$,watchDrag:Q,watchFocus:X}=m,Y={measure:function(n){const{offsetTop:t,offsetLeft:e,offsetWidth:r,offsetHeight:o}=n;return{top:t,right:e+r,bottom:t+o,left:e,width:r,height:o}}},J=Y.measure(e),K=r.map(Y.measure),Z=function(n,t){const e="rtl"===t,r="y"===n,o=!r&&e?-1:1;return{scroll:r?"y":"x",cross:r?"x":"y",startEdge:r?"top":e?"right":"left",endEdge:r?"bottom":e?"left":"right",measureSize:function(n){const{height:t,width:e}=n;return r?t:e},direction:function(n){return n*o}}}(H,k),_=Z.measureSize(J),nn=function(n){return{measure:function(t){return n*(t/100)}}}(_),tn=function(n,e){const r={start:function(){return 0},center:function(n){return o(n)/2},end:o};function o(n){return e-n}return{measure:function(o,i){return t(n)?r[n](o):n(e,o,i)}}}(z,_),en=!B&&!!U,rn=B||!!U,{slideSizes:on,slideSizesWithGaps:cn,startGap:un,endGap:sn}=function(n,t,e,r,i,c){const{measureSize:u,startEdge:a,endEdge:l}=n,f=e[0]&&i,p=function(){if(!f)return 0;const n=e[0];return o(t[a]-n[a])}(),m=function(){if(!f)return 0;const n=c.getComputedStyle(s(r));return parseFloat(n.getPropertyValue(`margin-${l}`))}(),g=e.map(u),h=e.map(((n,t,e)=>{const r=!t,o=d(e,t);return r?g[t]+p:o?g[t]+m:e[t+1][a]-n[a]})).map(o);return{slideSizes:g,slideSizesWithGaps:h,startGap:p,endGap:m}}(Z,J,K,r,rn,p),an=O(Z,_,G,B,J,K,un,sn,2),{snaps:dn,snapsAligned:ln}=function(n,t,e,r,i){const{startEdge:c,endEdge:u}=n,{groupSlides:a}=i,d=a(r).map((n=>s(n)[u]-n[0][c])).map(o).map(t.measure),l=r.map((n=>e[c]-n[c])).map((n=>-o(n))),f=a(l).map((n=>n[0])).map(((n,t)=>n+d[t]));return{snaps:l,snapsAligned:f}}(Z,tn,J,K,an),fn=-s(dn)+s(cn),{snapsContained:pn,scrollContainLimit:mn}=function(n,t,e,r,o){const i=x(-t+n,0),u=e.map(((n,t)=>{const{min:r,max:o}=i,c=i.constrain(n),u=!t,s=d(e,t);return u?o:s||l(r,c)?r:l(o,c)?o:c})).map((n=>parseFloat(n.toFixed(3)))),a=function(){const n=u[0],t=s(u);return x(u.lastIndexOf(n),u.indexOf(t)+1)}();function l(n,t){return c(n,t)<=1}return{snapsContained:function(){if(t<=n+o)return[i.max];if("keepSnaps"===r)return u;const{min:e,max:c}=a;return u.slice(e,c)}(),scrollContainLimit:a}}(_,fn,ln,U,2),gn=en?pn:ln,{limit:hn}=function(n,t,e){const r=t[0];return{limit:x(e?r-n:s(t),r)}}(fn,gn,B),xn=y(a(gn),V,B),yn=xn.clone(),vn=u(r),bn=h(f,p,(()=>(({dragHandler:n,scrollBody:t,scrollBounds:e,options:{loop:r}})=>{r||e.constrain(n.pointerDown()),t.seek()})(Hn)),(n=>(({scrollBody:n,translate:t,location:e,offsetLocation:r,previousLocation:o,scrollLooper:i,slideLooper:c,dragHandler:u,animation:s,eventHandler:a,scrollBounds:d,options:{loop:l}},f)=>{const p=n.settled(),m=!d.shouldConstrain(),g=l?p:p&&m,h=g&&!u.pointerDown();h&&s.stop();const x=e.get()*f+o.get()*(1-f);r.set(x),l&&(i.loop(n.direction()),c.loop()),t.to(r.get()),h&&a.emit("settle"),g||a.emit("scroll")})(Hn,n))),Sn=gn[xn.get()],wn=I(Sn),En=I(Sn),Ln=I(Sn),Dn=I(Sn),In=function(n,t,e,r,c,u){let s=0,a=0,d=c,l=u,f=n.get(),p=0;function m(n){return d=n,h}function g(n){return l=n,h}const h={direction:function(){return a},duration:function(){return d},velocity:function(){return s},seek:function(){const t=r.get()-n.get();let o=0;return d?(e.set(n),s+=t/d,s*=l,f+=s,n.add(s),o=f-p):(s=0,e.set(r),n.set(r),o=t),a=i(o),p=f,h},settled:function(){return o(r.get()-t.get())<.001},useBaseFriction:function(){return g(u)},useBaseDuration:function(){return m(c)},useFriction:g,useDuration:m};return h}(wn,Ln,En,Dn,C,.68),Mn=L(B,gn,fn,hn,Dn),An=function(n,t,e,r,o,i,c){function u(o){const u=o.distance,s=o.index!==t.get();i.add(u),u&&(r.duration()?n.start():(n.update(),n.render(1),n.update())),s&&(e.set(t.get()),t.set(o.index),c.emit("select"))}return{distance:function(n,t){u(o.byDistance(n,t))},index:function(n,e){const r=t.clone().set(n);u(o.byIndex(r.get(),e))}}}(bn,xn,yn,In,Mn,Dn,P),Fn=function(n){const{max:t,length:e}=n;return{get:function(n){return e?(n-t)/-e:0}}}(hn),Tn=g(),On=T(e,r,P,j),{slideRegistry:Pn}=function(n,t,e,r,o,i){const{groupSlides:c}=o,{min:u,max:f}=r;return{slideRegistry:function(){const r=c(i),o=!n||"keepSnaps"===t;return 1===e.length?[i]:o?r:r.slice(u,f).map(((n,t,e)=>{const r=!t,o=d(e,t);return r?l(s(e[0])+1):o?l(a(i)-s(e)[0]+1,s(e)[0]):n}))}()}}(en,U,gn,mn,an,vn),zn=D(n,r,Pn,An,In,Tn,P,X),Hn={ownerDocument:f,ownerWindow:p,eventHandler:P,containerRect:J,slideRects:K,animation:bn,axis:Z,dragHandler:v(Z,n,f,p,Dn,b(Z,p),wn,bn,An,In,Mn,xn,P,nn,N,R,q,.68,Q),eventStore:Tn,percentOfView:nn,index:xn,indexPrevious:yn,limit:hn,location:wn,offsetLocation:Ln,previousLocation:En,options:m,resizeHandler:S(e,P,p,r,Z,W,Y),scrollBody:In,scrollBounds:w(hn,Ln,Dn,In,nn),scrollLooper:E(fn,hn,Ln,[wn,Ln,En,Dn]),scrollProgress:Fn,scrollSnapList:gn.map(Fn.get),scrollSnaps:gn,scrollTarget:Mn,scrollTo:An,slideLooper:A(Z,_,fn,on,cn,dn,gn,Ln,r),slideFocus:zn,slidesHandler:F(e,P,$),slidesInView:On,slideIndexes:vn,slideRegistry:Pn,slidesToScroll:an,target:Dn,translate:M(Z,e)};return Hn}const z={align:"center",axis:"x",container:null,slides:null,containScroll:"trimSnaps",direction:"ltr",slidesToScroll:1,inViewThreshold:0,breakpoints:{},dragFree:!1,dragThreshold:10,loop:!1,skipSnaps:!1,duration:25,startIndex:0,active:!0,watchDrag:!0,watchResize:!0,watchSlides:!0,watchFocus:!0};function H(n){function t(n,t){return p(n,t||{})}const e={mergeOptions:t,optionsAtMedia:function(e){const r=e.breakpoints||{},o=f(r).filter((t=>n.matchMedia(t).matches)).map((n=>r[n])).reduce(((n,e)=>t(n,e)),{});return t(e,o)},optionsMediaQueries:function(t){return t.map((n=>f(n.breakpoints||{}))).reduce(((n,t)=>n.concat(t)),[]).map(n.matchMedia)}};return e}function k(n,e,r){const o=n.ownerDocument,i=o.defaultView,c=H(i),u=function(n){let t=[];return{init:function(e,r){return t=r.filter((({options:t})=>!1!==n.optionsAtMedia(t).active)),t.forEach((t=>t.init(e,n))),r.reduce(((n,t)=>Object.assign(n,{[t.name]:t})),{})},destroy:function(){t=t.filter((n=>n.destroy()))}}}(c),s=g(),a=function(){let n,t={};function e(n){return t[n]||[]}const r={init:function(t){n=t},emit:function(t){return e(t).forEach((e=>e(n,t))),r},off:function(n,o){return t[n]=e(n).filter((n=>n!==o)),r},on:function(n,o){return t[n]=e(n).concat([o]),r},clear:function(){t={}}};return r}(),{mergeOptions:d,optionsAtMedia:l,optionsMediaQueries:f}=c,{on:p,off:m,emit:h}=a,x=A;let y,v,b,S,w=!1,E=d(z,k.globalOptions),L=d(E),D=[];function I(t){const e=P(n,b,S,o,i,t,a);if(t.loop&&!e.slideLooper.canLoop()){return I(Object.assign({},t,{loop:!1}))}return e}function M(e,r){w||(E=d(E,e),L=l(E),D=r||D,function(){const{container:e,slides:r}=L,o=t(e)?n.querySelector(e):e;b=o||n.children[0];const i=t(r)?b.querySelectorAll(r):r;S=[].slice.call(i||b.children)}(),y=I(L),f([E,...D.map((({options:n})=>n))]).forEach((n=>s.add(n,"change",A))),L.active&&(y.translate.to(y.location.get()),y.animation.init(),y.slidesInView.init(),y.slideFocus.init(V),y.eventHandler.init(V),y.resizeHandler.init(V),y.slidesHandler.init(V),y.options.loop&&y.slideLooper.loop(),b.offsetParent&&S.length&&y.dragHandler.init(V),v=u.init(V,D)))}function A(n,t){const e=O();F(),M(d({startIndex:e},n),t),a.emit("reInit")}function F(){y.dragHandler.destroy(),y.eventStore.clear(),y.translate.clear(),y.slideLooper.clear(),y.resizeHandler.destroy(),y.slidesHandler.destroy(),y.slidesInView.destroy(),y.animation.destroy(),u.destroy(),s.clear()}function T(n,t,e){L.active&&!w&&(y.scrollBody.useBaseFriction().useDuration(!0===t?0:L.duration),y.scrollTo.index(n,e||0))}function O(){return y.index.get()}const V={canScrollNext:function(){return y.index.add(1).get()!==O()},canScrollPrev:function(){return y.index.add(-1).get()!==O()},containerNode:function(){return b},internalEngine:function(){return y},destroy:function(){w||(w=!0,s.clear(),F(),a.emit("destroy"),a.clear())},off:m,on:p,emit:h,plugins:function(){return v},previousScrollSnap:function(){return y.indexPrevious.get()},reInit:x,rootNode:function(){return n},scrollNext:function(n){T(y.index.add(1).get(),n,-1)},scrollPrev:function(n){T(y.index.add(-1).get(),n,1)},scrollProgress:function(){return y.scrollProgress.get(y.offsetLocation.get())},scrollSnapList:function(){return y.scrollSnapList},scrollTo:T,selectedScrollSnap:O,slideNodes:function(){return S},slidesInView:function(){return y.slidesInView.get()},slidesNotInView:function(){return y.slidesInView.get(!1)}};return M(e,r),setTimeout((()=>a.emit("init")),0),V}return k.globalOptions=void 0,k})); diff --git a/solution/site/scripts/mockup/vendor/index-D9cPXCE3.js b/solution/site/scripts/mockup/vendor/index-D9cPXCE3.js new file mode 100644 index 0000000..2db0b85 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/index-D9cPXCE3.js @@ -0,0 +1,340 @@ +(function(){const c=document.createElement("link").relList;if(c&&c.supports&&c.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))u(f);new MutationObserver(f=>{for(const d of f)if(d.type==="childList")for(const h of d.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&u(h)}).observe(document,{childList:!0,subtree:!0});function o(f){const d={};return f.integrity&&(d.integrity=f.integrity),f.referrerPolicy&&(d.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?d.credentials="include":f.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(f){if(f.ep)return;f.ep=!0;const d=o(f);fetch(f.href,d)}})();const Pg="modulepreload",eb=function(i){return"/"+i},xh={},tb=function(c,o,u){let f=Promise.resolve();if(o&&o.length>0){let h=function(x){return Promise.all(x.map(v=>Promise.resolve(v).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const p=document.querySelector("meta[property=csp-nonce]"),g=(p==null?void 0:p.nonce)||(p==null?void 0:p.getAttribute("nonce"));f=h(o.map(x=>{if(x=eb(x),x in xh)return;xh[x]=!0;const v=x.endsWith(".css"),y=v?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${x}"]${y}`))return;const z=document.createElement("link");if(z.rel=v?"stylesheet":Pg,v||(z.as="script"),z.crossOrigin="",z.href=x,g&&z.setAttribute("nonce",g),document.head.appendChild(z),v)return new Promise((w,N)=>{z.addEventListener("load",w),z.addEventListener("error",()=>N(new Error(`Unable to preload CSS for ${x}`)))})}))}function d(h){const p=new Event("vite:preloadError",{cancelable:!0});if(p.payload=h,window.dispatchEvent(p),!p.defaultPrevented)throw h}return f.then(h=>{for(const p of h||[])p.status==="rejected"&&d(p.reason);return c().catch(d)})};var Mo={exports:{}},zs={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gh;function lb(){if(gh)return zs;gh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.fragment");function o(u,f,d){var h=null;if(d!==void 0&&(h=""+d),f.key!==void 0&&(h=""+f.key),"key"in f){d={};for(var p in f)p!=="key"&&(d[p]=f[p])}else d=f;return f=d.ref,{$$typeof:i,type:u,key:h,ref:f!==void 0?f:null,props:d}}return zs.Fragment=c,zs.jsx=o,zs.jsxs=o,zs}var bh;function ab(){return bh||(bh=1,Mo.exports=lb()),Mo.exports}var n=ab(),ko={exports:{}},ue={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var vh;function nb(){if(vh)return ue;vh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),h=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),v=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),z=Symbol.iterator;function w(S){return S===null||typeof S!="object"?null:(S=z&&S[z]||S["@@iterator"],typeof S=="function"?S:null)}var N={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},A=Object.assign,D={};function U(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}U.prototype.isReactComponent={},U.prototype.setState=function(S,L){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,L,"setState")},U.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function X(){}X.prototype=U.prototype;function Z(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}var P=Z.prototype=new X;P.constructor=Z,A(P,U.prototype),P.isPureReactComponent=!0;var $=Array.isArray;function K(){}var G={H:null,A:null,T:null,S:null},ee=Object.prototype.hasOwnProperty;function se(S,L,V){var J=V.ref;return{$$typeof:i,type:S,key:L,ref:J!==void 0?J:null,props:V}}function Q(S,L){return se(S.type,L,S.props)}function ae(S){return typeof S=="object"&&S!==null&&S.$$typeof===i}function ye(S){var L={"=":"=0",":":"=2"};return"$"+S.replace(/[=:]/g,function(V){return L[V]})}var Ge=/\/+/g;function Qe(S,L){return typeof S=="object"&&S!==null&&S.key!=null?ye(""+S.key):L.toString(36)}function ce(S){switch(S.status){case"fulfilled":return S.value;case"rejected":throw S.reason;default:switch(typeof S.status=="string"?S.then(K,K):(S.status="pending",S.then(function(L){S.status==="pending"&&(S.status="fulfilled",S.value=L)},function(L){S.status==="pending"&&(S.status="rejected",S.reason=L)})),S.status){case"fulfilled":return S.value;case"rejected":throw S.reason}}throw S}function k(S,L,V,J,re){var fe=typeof S;(fe==="undefined"||fe==="boolean")&&(S=null);var oe=!1;if(S===null)oe=!0;else switch(fe){case"bigint":case"string":case"number":oe=!0;break;case"object":switch(S.$$typeof){case i:case c:oe=!0;break;case v:return oe=S._init,k(oe(S._payload),L,V,J,re)}}if(oe)return re=re(S),oe=J===""?"."+Qe(S,0):J,$(re)?(V="",oe!=null&&(V=oe.replace(Ge,"$&/")+"/"),k(re,L,V,"",function(at){return at})):re!=null&&(ae(re)&&(re=Q(re,V+(re.key==null||S&&S.key===re.key?"":(""+re.key).replace(Ge,"$&/")+"/")+oe)),L.push(re)),1;oe=0;var ze=J===""?".":J+":";if($(S))for(var Ee=0;Ee<S.length;Ee++)J=S[Ee],fe=ze+Qe(J,Ee),oe+=k(J,L,V,fe,re);else if(Ee=w(S),typeof Ee=="function")for(S=Ee.call(S),Ee=0;!(J=S.next()).done;)J=J.value,fe=ze+Qe(J,Ee++),oe+=k(J,L,V,fe,re);else if(fe==="object"){if(typeof S.then=="function")return k(ce(S),L,V,J,re);throw L=String(S),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(S).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return oe}function Y(S,L,V){if(S==null)return S;var J=[],re=0;return k(S,J,"","",function(fe){return L.call(V,fe,re++)}),J}function te(S){if(S._status===-1){var L=S._result;L=L(),L.then(function(V){(S._status===0||S._status===-1)&&(S._status=1,S._result=V)},function(V){(S._status===0||S._status===-1)&&(S._status=2,S._result=V)}),S._status===-1&&(S._status=0,S._result=L)}if(S._status===1)return S._result.default;throw S._result}var he=typeof reportError=="function"?reportError:function(S){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof S=="object"&&S!==null&&typeof S.message=="string"?String(S.message):String(S),error:S});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",S);return}console.error(S)},je={map:Y,forEach:function(S,L,V){Y(S,function(){L.apply(this,arguments)},V)},count:function(S){var L=0;return Y(S,function(){L++}),L},toArray:function(S){return Y(S,function(L){return L})||[]},only:function(S){if(!ae(S))throw Error("React.Children.only expected to receive a single React element child.");return S}};return ue.Activity=y,ue.Children=je,ue.Component=U,ue.Fragment=o,ue.Profiler=f,ue.PureComponent=Z,ue.StrictMode=u,ue.Suspense=g,ue.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=G,ue.__COMPILER_RUNTIME={__proto__:null,c:function(S){return G.H.useMemoCache(S)}},ue.cache=function(S){return function(){return S.apply(null,arguments)}},ue.cacheSignal=function(){return null},ue.cloneElement=function(S,L,V){if(S==null)throw Error("The argument must be a React element, but you passed "+S+".");var J=A({},S.props),re=S.key;if(L!=null)for(fe in L.key!==void 0&&(re=""+L.key),L)!ee.call(L,fe)||fe==="key"||fe==="__self"||fe==="__source"||fe==="ref"&&L.ref===void 0||(J[fe]=L[fe]);var fe=arguments.length-2;if(fe===1)J.children=V;else if(1<fe){for(var oe=Array(fe),ze=0;ze<fe;ze++)oe[ze]=arguments[ze+2];J.children=oe}return se(S.type,re,J)},ue.createContext=function(S){return S={$$typeof:h,_currentValue:S,_currentValue2:S,_threadCount:0,Provider:null,Consumer:null},S.Provider=S,S.Consumer={$$typeof:d,_context:S},S},ue.createElement=function(S,L,V){var J,re={},fe=null;if(L!=null)for(J in L.key!==void 0&&(fe=""+L.key),L)ee.call(L,J)&&J!=="key"&&J!=="__self"&&J!=="__source"&&(re[J]=L[J]);var oe=arguments.length-2;if(oe===1)re.children=V;else if(1<oe){for(var ze=Array(oe),Ee=0;Ee<oe;Ee++)ze[Ee]=arguments[Ee+2];re.children=ze}if(S&&S.defaultProps)for(J in oe=S.defaultProps,oe)re[J]===void 0&&(re[J]=oe[J]);return se(S,fe,re)},ue.createRef=function(){return{current:null}},ue.forwardRef=function(S){return{$$typeof:p,render:S}},ue.isValidElement=ae,ue.lazy=function(S){return{$$typeof:v,_payload:{_status:-1,_result:S},_init:te}},ue.memo=function(S,L){return{$$typeof:x,type:S,compare:L===void 0?null:L}},ue.startTransition=function(S){var L=G.T,V={};G.T=V;try{var J=S(),re=G.S;re!==null&&re(V,J),typeof J=="object"&&J!==null&&typeof J.then=="function"&&J.then(K,he)}catch(fe){he(fe)}finally{L!==null&&V.types!==null&&(L.types=V.types),G.T=L}},ue.unstable_useCacheRefresh=function(){return G.H.useCacheRefresh()},ue.use=function(S){return G.H.use(S)},ue.useActionState=function(S,L,V){return G.H.useActionState(S,L,V)},ue.useCallback=function(S,L){return G.H.useCallback(S,L)},ue.useContext=function(S){return G.H.useContext(S)},ue.useDebugValue=function(){},ue.useDeferredValue=function(S,L){return G.H.useDeferredValue(S,L)},ue.useEffect=function(S,L){return G.H.useEffect(S,L)},ue.useEffectEvent=function(S){return G.H.useEffectEvent(S)},ue.useId=function(){return G.H.useId()},ue.useImperativeHandle=function(S,L,V){return G.H.useImperativeHandle(S,L,V)},ue.useInsertionEffect=function(S,L){return G.H.useInsertionEffect(S,L)},ue.useLayoutEffect=function(S,L){return G.H.useLayoutEffect(S,L)},ue.useMemo=function(S,L){return G.H.useMemo(S,L)},ue.useOptimistic=function(S,L){return G.H.useOptimistic(S,L)},ue.useReducer=function(S,L,V){return G.H.useReducer(S,L,V)},ue.useRef=function(S){return G.H.useRef(S)},ue.useState=function(S){return G.H.useState(S)},ue.useSyncExternalStore=function(S,L,V){return G.H.useSyncExternalStore(S,L,V)},ue.useTransition=function(){return G.H.useTransition()},ue.version="19.2.8",ue}var yh;function Fo(){return yh||(yh=1,ko.exports=nb()),ko.exports}var q=Fo(),Oo={exports:{}},Es={},Do={exports:{}},Ro={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jh;function sb(){return jh||(jh=1,(function(i){function c(k,Y){var te=k.length;k.push(Y);e:for(;0<te;){var he=te-1>>>1,je=k[he];if(0<f(je,Y))k[he]=Y,k[te]=je,te=he;else break e}}function o(k){return k.length===0?null:k[0]}function u(k){if(k.length===0)return null;var Y=k[0],te=k.pop();if(te!==Y){k[0]=te;e:for(var he=0,je=k.length,S=je>>>1;he<S;){var L=2*(he+1)-1,V=k[L],J=L+1,re=k[J];if(0>f(V,te))J<je&&0>f(re,V)?(k[he]=re,k[J]=te,he=J):(k[he]=V,k[L]=te,he=L);else if(J<je&&0>f(re,te))k[he]=re,k[J]=te,he=J;else break e}}return Y}function f(k,Y){var te=k.sortIndex-Y.sortIndex;return te!==0?te:k.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;i.unstable_now=function(){return d.now()}}else{var h=Date,p=h.now();i.unstable_now=function(){return h.now()-p}}var g=[],x=[],v=1,y=null,z=3,w=!1,N=!1,A=!1,D=!1,U=typeof setTimeout=="function"?setTimeout:null,X=typeof clearTimeout=="function"?clearTimeout:null,Z=typeof setImmediate<"u"?setImmediate:null;function P(k){for(var Y=o(x);Y!==null;){if(Y.callback===null)u(x);else if(Y.startTime<=k)u(x),Y.sortIndex=Y.expirationTime,c(g,Y);else break;Y=o(x)}}function $(k){if(A=!1,P(k),!N)if(o(g)!==null)N=!0,K||(K=!0,ye());else{var Y=o(x);Y!==null&&ce($,Y.startTime-k)}}var K=!1,G=-1,ee=5,se=-1;function Q(){return D?!0:!(i.unstable_now()-se<ee)}function ae(){if(D=!1,K){var k=i.unstable_now();se=k;var Y=!0;try{e:{N=!1,A&&(A=!1,X(G),G=-1),w=!0;var te=z;try{t:{for(P(k),y=o(g);y!==null&&!(y.expirationTime>k&&Q());){var he=y.callback;if(typeof he=="function"){y.callback=null,z=y.priorityLevel;var je=he(y.expirationTime<=k);if(k=i.unstable_now(),typeof je=="function"){y.callback=je,P(k),Y=!0;break t}y===o(g)&&u(g),P(k)}else u(g);y=o(g)}if(y!==null)Y=!0;else{var S=o(x);S!==null&&ce($,S.startTime-k),Y=!1}}break e}finally{y=null,z=te,w=!1}Y=void 0}}finally{Y?ye():K=!1}}}var ye;if(typeof Z=="function")ye=function(){Z(ae)};else if(typeof MessageChannel<"u"){var Ge=new MessageChannel,Qe=Ge.port2;Ge.port1.onmessage=ae,ye=function(){Qe.postMessage(null)}}else ye=function(){U(ae,0)};function ce(k,Y){G=U(function(){k(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(k){k.callback=null},i.unstable_forceFrameRate=function(k){0>k||125<k?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):ee=0<k?Math.floor(1e3/k):5},i.unstable_getCurrentPriorityLevel=function(){return z},i.unstable_next=function(k){switch(z){case 1:case 2:case 3:var Y=3;break;default:Y=z}var te=z;z=Y;try{return k()}finally{z=te}},i.unstable_requestPaint=function(){D=!0},i.unstable_runWithPriority=function(k,Y){switch(k){case 1:case 2:case 3:case 4:case 5:break;default:k=3}var te=z;z=k;try{return Y()}finally{z=te}},i.unstable_scheduleCallback=function(k,Y,te){var he=i.unstable_now();switch(typeof te=="object"&&te!==null?(te=te.delay,te=typeof te=="number"&&0<te?he+te:he):te=he,k){case 1:var je=-1;break;case 2:je=250;break;case 5:je=1073741823;break;case 4:je=1e4;break;default:je=5e3}return je=te+je,k={id:v++,callback:Y,priorityLevel:k,startTime:te,expirationTime:je,sortIndex:-1},te>he?(k.sortIndex=te,c(x,k),o(g)===null&&k===o(x)&&(A?(X(G),G=-1):A=!0,ce($,te-he))):(k.sortIndex=je,c(g,k),N||w||(N=!0,K||(K=!0,ye()))),k},i.unstable_shouldYield=Q,i.unstable_wrapCallback=function(k){var Y=z;return function(){var te=z;z=Y;try{return k.apply(this,arguments)}finally{z=te}}}})(Ro)),Ro}var Nh;function ib(){return Nh||(Nh=1,Do.exports=sb()),Do.exports}var Uo={exports:{}},mt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sh;function cb(){if(Sh)return mt;Sh=1;var i=Fo();function c(g){var x="https://react.dev/errors/"+g;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var v=2;v<arguments.length;v++)x+="&args[]="+encodeURIComponent(arguments[v])}return"Minified React error #"+g+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function o(){}var u={d:{f:o,r:function(){throw Error(c(522))},D:o,C:o,L:o,m:o,X:o,S:o,M:o},p:0,findDOMNode:null},f=Symbol.for("react.portal");function d(g,x,v){var y=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:f,key:y==null?null:""+y,children:g,containerInfo:x,implementation:v}}var h=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function p(g,x){if(g==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return mt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=u,mt.createPortal=function(g,x){var v=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(c(299));return d(g,x,null,v)},mt.flushSync=function(g){var x=h.T,v=u.p;try{if(h.T=null,u.p=2,g)return g()}finally{h.T=x,u.p=v,u.d.f()}},mt.preconnect=function(g,x){typeof g=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,u.d.C(g,x))},mt.prefetchDNS=function(g){typeof g=="string"&&u.d.D(g)},mt.preinit=function(g,x){if(typeof g=="string"&&x&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin),z=typeof x.integrity=="string"?x.integrity:void 0,w=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;v==="style"?u.d.S(g,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:y,integrity:z,fetchPriority:w}):v==="script"&&u.d.X(g,{crossOrigin:y,integrity:z,fetchPriority:w,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},mt.preinitModule=function(g,x){if(typeof g=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var v=p(x.as,x.crossOrigin);u.d.M(g,{crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0})}}else x==null&&u.d.M(g)},mt.preload=function(g,x){if(typeof g=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin);u.d.L(g,v,{crossOrigin:y,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},mt.preloadModule=function(g,x){if(typeof g=="string")if(x){var v=p(x.as,x.crossOrigin);u.d.m(g,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0})}else u.d.m(g)},mt.requestFormReset=function(g){u.d.r(g)},mt.unstable_batchedUpdates=function(g,x){return g(x)},mt.useFormState=function(g,x,v){return h.H.useFormState(g,x,v)},mt.useFormStatus=function(){return h.H.useHostTransitionStatus()},mt.version="19.2.8",mt}var zh;function rb(){if(zh)return Uo.exports;zh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Uo.exports=cb(),Uo.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Eh;function ob(){if(Eh)return Es;Eh=1;var i=ib(),c=Fo(),o=rb();function u(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)t+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function f(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function d(e){var t=e,l=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do t=e,(t.flags&4098)!==0&&(l=t.return),e=t.return;while(e)}return t.tag===3?l:null}function h(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function p(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function g(e){if(d(e)!==e)throw Error(u(188))}function x(e){var t=e.alternate;if(!t){if(t=d(e),t===null)throw Error(u(188));return t!==e?null:e}for(var l=e,a=t;;){var s=l.return;if(s===null)break;var r=s.alternate;if(r===null){if(a=s.return,a!==null){l=a;continue}break}if(s.child===r.child){for(r=s.child;r;){if(r===l)return g(s),e;if(r===a)return g(s),t;r=r.sibling}throw Error(u(188))}if(l.return!==a.return)l=s,a=r;else{for(var m=!1,b=s.child;b;){if(b===l){m=!0,l=s,a=r;break}if(b===a){m=!0,a=s,l=r;break}b=b.sibling}if(!m){for(b=r.child;b;){if(b===l){m=!0,l=r,a=s;break}if(b===a){m=!0,a=r,l=s;break}b=b.sibling}if(!m)throw Error(u(189))}}if(l.alternate!==a)throw Error(u(190))}if(l.tag!==3)throw Error(u(188));return l.stateNode.current===l?e:t}function v(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=v(e),t!==null)return t;e=e.sibling}return null}var y=Object.assign,z=Symbol.for("react.element"),w=Symbol.for("react.transitional.element"),N=Symbol.for("react.portal"),A=Symbol.for("react.fragment"),D=Symbol.for("react.strict_mode"),U=Symbol.for("react.profiler"),X=Symbol.for("react.consumer"),Z=Symbol.for("react.context"),P=Symbol.for("react.forward_ref"),$=Symbol.for("react.suspense"),K=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),ee=Symbol.for("react.lazy"),se=Symbol.for("react.activity"),Q=Symbol.for("react.memo_cache_sentinel"),ae=Symbol.iterator;function ye(e){return e===null||typeof e!="object"?null:(e=ae&&e[ae]||e["@@iterator"],typeof e=="function"?e:null)}var Ge=Symbol.for("react.client.reference");function Qe(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===Ge?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case A:return"Fragment";case U:return"Profiler";case D:return"StrictMode";case $:return"Suspense";case K:return"SuspenseList";case se:return"Activity"}if(typeof e=="object")switch(e.$$typeof){case N:return"Portal";case Z:return e.displayName||"Context";case X:return(e._context.displayName||"Context")+".Consumer";case P:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case G:return t=e.displayName||null,t!==null?t:Qe(e.type)||"Memo";case ee:t=e._payload,e=e._init;try{return Qe(e(t))}catch{}}return null}var ce=Array.isArray,k=c.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Y=o.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,te={pending:!1,data:null,method:null,action:null},he=[],je=-1;function S(e){return{current:e}}function L(e){0>je||(e.current=he[je],he[je]=null,je--)}function V(e,t){je++,he[je]=e.current,e.current=t}var J=S(null),re=S(null),fe=S(null),oe=S(null);function ze(e,t){switch(V(fe,t),V(re,e),V(J,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Bm(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Bm(t),e=qm(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}L(J),V(J,e)}function Ee(){L(J),L(re),L(fe)}function at(e){e.memoizedState!==null&&V(oe,e);var t=J.current,l=qm(t,e.type);t!==l&&(V(re,e),V(J,l))}function Me(e){re.current===e&&(L(J),L(re)),oe.current===e&&(L(oe),ys._currentValue=te)}var I,Te;function ke(e){if(I===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);I=t&&t[1]||"",Te=-1<l.stack.indexOf(` + at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return` +`+I+e+Te}var tt=!1;function yt(e,t){if(!e||tt)return"";tt=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var B=function(){throw Error()};if(Object.defineProperty(B.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(B,[])}catch(O){var M=O}Reflect.construct(e,[],B)}else{try{B.call()}catch(O){M=O}e.call(B.prototype)}}else{try{throw Error()}catch(O){M=O}(B=e())&&typeof B.catch=="function"&&B.catch(function(){})}}catch(O){if(O&&M&&typeof O.stack=="string")return[O.stack,M.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var s=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");s&&s.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var r=a.DetermineComponentFrameRoot(),m=r[0],b=r[1];if(m&&b){var j=m.split(` +`),C=b.split(` +`);for(s=a=0;a<j.length&&!j[a].includes("DetermineComponentFrameRoot");)a++;for(;s<C.length&&!C[s].includes("DetermineComponentFrameRoot");)s++;if(a===j.length||s===C.length)for(a=j.length-1,s=C.length-1;1<=a&&0<=s&&j[a]!==C[s];)s--;for(;1<=a&&0<=s;a--,s--)if(j[a]!==C[s]){if(a!==1||s!==1)do if(a--,s--,0>s||j[a]!==C[s]){var R=` +`+j[a].replace(" at new "," at ");return e.displayName&&R.includes("<anonymous>")&&(R=R.replace("<anonymous>",e.displayName)),R}while(1<=a&&0<=s);break}}}finally{tt=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?ke(l):""}function Jt(e,t){switch(e.tag){case 26:case 27:case 5:return ke(e.type);case 16:return ke("Lazy");case 13:return e.child!==t&&t!==null?ke("Suspense Fallback"):ke("Suspense");case 19:return ke("SuspenseList");case 0:case 15:return yt(e.type,!1);case 11:return yt(e.type.render,!1);case 1:return yt(e.type,!0);case 31:return ke("Activity");default:return""}}function ll(e){try{var t="",l=null;do t+=Jt(e,l),l=e,e=e.return;while(e);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ht=Object.prototype.hasOwnProperty,Wt=i.unstable_scheduleCallback,hl=i.unstable_cancelCallback,An=i.unstable_shouldYield,_n=i.unstable_requestPaint,dt=i.unstable_now,$s=i.unstable_getCurrentPriorityLevel,Qs=i.unstable_ImmediatePriority,Cn=i.unstable_UserBlockingPriority,Da=i.unstable_NormalPriority,Mn=i.unstable_LowPriority,pl=i.unstable_IdlePriority,kn=i.log,On=i.unstable_setDisableYieldValue,al=null,pt=null;function nl(e){if(typeof kn=="function"&&On(e),pt&&typeof pt.setStrictMode=="function")try{pt.setStrictMode(al,e)}catch{}}var xt=Math.clz32?Math.clz32:Xs,xc=Math.log,gc=Math.LN2;function Xs(e){return e>>>=0,e===0?32:31-(xc(e)/gc|0)|0}var Ra=256,da=262144,ma=4194304;function sl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ua(e,t,l){var a=e.pendingLanes;if(a===0)return 0;var s=0,r=e.suspendedLanes,m=e.pingedLanes;e=e.warmLanes;var b=a&134217727;return b!==0?(a=b&~r,a!==0?s=sl(a):(m&=b,m!==0?s=sl(m):l||(l=b&~e,l!==0&&(s=sl(l))))):(b=a&~r,b!==0?s=sl(b):m!==0?s=sl(m):l||(l=a&~e,l!==0&&(s=sl(l)))),s===0?0:t!==0&&t!==s&&(t&r)===0&&(r=s&-s,l=t&-t,r>=l||r===32&&(l&4194048)!==0)?t:s}function Ul(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Vs(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Zs(){var e=ma;return ma<<=1,(ma&62914560)===0&&(ma=4194304),e}function bc(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function Dn(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Yp(e,t,l,a,s,r){var m=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var b=e.entanglements,j=e.expirationTimes,C=e.hiddenUpdates;for(l=m&~l;0<l;){var R=31-xt(l),B=1<<R;b[R]=0,j[R]=-1;var M=C[R];if(M!==null)for(C[R]=null,R=0;R<M.length;R++){var O=M[R];O!==null&&(O.lane&=-536870913)}l&=~B}a!==0&&yu(e,a,0),r!==0&&s===0&&e.tag!==0&&(e.suspendedLanes|=r&~(m&~t))}function yu(e,t,l){e.pendingLanes|=t,e.suspendedLanes&=~t;var a=31-xt(t);e.entangledLanes|=t,e.entanglements[a]=e.entanglements[a]|1073741824|l&261930}function ju(e,t){var l=e.entangledLanes|=t;for(e=e.entanglements;l;){var a=31-xt(l),s=1<<a;s&t|e[a]&t&&(e[a]|=t),l&=~s}}function Nu(e,t){var l=t&-t;return l=(l&42)!==0?1:vc(l),(l&(e.suspendedLanes|t))!==0?0:l}function vc(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function yc(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function Su(){var e=Y.p;return e!==0?e:(e=window.event,e===void 0?32:oh(e.type))}function zu(e,t){var l=Y.p;try{return Y.p=e,t()}finally{Y.p=l}}var Hl=Math.random().toString(36).slice(2),it="__reactFiber$"+Hl,jt="__reactProps$"+Hl,Ha="__reactContainer$"+Hl,jc="__reactEvents$"+Hl,Gp="__reactListeners$"+Hl,$p="__reactHandles$"+Hl,Eu="__reactResources$"+Hl,Rn="__reactMarker$"+Hl;function Nc(e){delete e[it],delete e[jt],delete e[jc],delete e[Gp],delete e[$p]}function La(e){var t=e[it];if(t)return t;for(var l=e.parentNode;l;){if(t=l[Ha]||l[it]){if(l=t.alternate,t.child!==null||l!==null&&l.child!==null)for(e=Zm(e);e!==null;){if(l=e[it])return l;e=Zm(e)}return t}e=l,l=e.parentNode}return null}function Ba(e){if(e=e[it]||e[Ha]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Un(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(u(33))}function qa(e){var t=e[Eu];return t||(t=e[Eu]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function nt(e){e[Rn]=!0}var wu=new Set,Tu={};function ha(e,t){Ya(e,t),Ya(e+"Capture",t)}function Ya(e,t){for(Tu[e]=t,e=0;e<t.length;e++)wu.add(t[e])}var Qp=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$"),Au={},_u={};function Xp(e){return Ht.call(_u,e)?!0:Ht.call(Au,e)?!1:Qp.test(e)?_u[e]=!0:(Au[e]=!0,!1)}function Ks(e,t,l){if(Xp(t))if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":e.removeAttribute(t);return;case"boolean":var a=t.toLowerCase().slice(0,5);if(a!=="data-"&&a!=="aria-"){e.removeAttribute(t);return}}e.setAttribute(t,""+l)}}function Js(e,t,l){if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(t);return}e.setAttribute(t,""+l)}}function xl(e,t,l,a){if(a===null)e.removeAttribute(l);else{switch(typeof a){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(l);return}e.setAttributeNS(t,l,""+a)}}function Lt(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Cu(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Vp(e,t,l){var a=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);if(!e.hasOwnProperty(t)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var s=a.get,r=a.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return s.call(this)},set:function(m){l=""+m,r.call(this,m)}}),Object.defineProperty(e,t,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(m){l=""+m},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Sc(e){if(!e._valueTracker){var t=Cu(e)?"checked":"value";e._valueTracker=Vp(e,t,""+e[t])}}function Mu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var l=t.getValue(),a="";return e&&(a=Cu(e)?e.checked?"true":"false":e.value),e=a,e!==l?(t.setValue(e),!0):!1}function Ws(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Zp=/[\n"\\]/g;function Bt(e){return e.replace(Zp,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function zc(e,t,l,a,s,r,m,b){e.name="",m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"?e.type=m:e.removeAttribute("type"),t!=null?m==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Lt(t)):e.value!==""+Lt(t)&&(e.value=""+Lt(t)):m!=="submit"&&m!=="reset"||e.removeAttribute("value"),t!=null?Ec(e,m,Lt(t)):l!=null?Ec(e,m,Lt(l)):a!=null&&e.removeAttribute("value"),s==null&&r!=null&&(e.defaultChecked=!!r),s!=null&&(e.checked=s&&typeof s!="function"&&typeof s!="symbol"),b!=null&&typeof b!="function"&&typeof b!="symbol"&&typeof b!="boolean"?e.name=""+Lt(b):e.removeAttribute("name")}function ku(e,t,l,a,s,r,m,b){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(e.type=r),t!=null||l!=null){if(!(r!=="submit"&&r!=="reset"||t!=null)){Sc(e);return}l=l!=null?""+Lt(l):"",t=t!=null?""+Lt(t):l,b||t===e.value||(e.value=t),e.defaultValue=t}a=a??s,a=typeof a!="function"&&typeof a!="symbol"&&!!a,e.checked=b?e.checked:!!a,e.defaultChecked=!!a,m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"&&(e.name=m),Sc(e)}function Ec(e,t,l){t==="number"&&Ws(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function Ga(e,t,l,a){if(e=e.options,t){t={};for(var s=0;s<l.length;s++)t["$"+l[s]]=!0;for(l=0;l<e.length;l++)s=t.hasOwnProperty("$"+e[l].value),e[l].selected!==s&&(e[l].selected=s),s&&a&&(e[l].defaultSelected=!0)}else{for(l=""+Lt(l),t=null,s=0;s<e.length;s++){if(e[s].value===l){e[s].selected=!0,a&&(e[s].defaultSelected=!0);return}t!==null||e[s].disabled||(t=e[s])}t!==null&&(t.selected=!0)}}function Ou(e,t,l){if(t!=null&&(t=""+Lt(t),t!==e.value&&(e.value=t),l==null)){e.defaultValue!==t&&(e.defaultValue=t);return}e.defaultValue=l!=null?""+Lt(l):""}function Du(e,t,l,a){if(t==null){if(a!=null){if(l!=null)throw Error(u(92));if(ce(a)){if(1<a.length)throw Error(u(93));a=a[0]}l=a}l==null&&(l=""),t=l}l=Lt(t),e.defaultValue=l,a=e.textContent,a===l&&a!==""&&a!==null&&(e.value=a),Sc(e)}function $a(e,t){if(t){var l=e.firstChild;if(l&&l===e.lastChild&&l.nodeType===3){l.nodeValue=t;return}}e.textContent=t}var Kp=new Set("animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp".split(" "));function Ru(e,t,l){var a=t.indexOf("--")===0;l==null||typeof l=="boolean"||l===""?a?e.setProperty(t,""):t==="float"?e.cssFloat="":e[t]="":a?e.setProperty(t,l):typeof l!="number"||l===0||Kp.has(t)?t==="float"?e.cssFloat=l:e[t]=(""+l).trim():e[t]=l+"px"}function Uu(e,t,l){if(t!=null&&typeof t!="object")throw Error(u(62));if(e=e.style,l!=null){for(var a in l)!l.hasOwnProperty(a)||t!=null&&t.hasOwnProperty(a)||(a.indexOf("--")===0?e.setProperty(a,""):a==="float"?e.cssFloat="":e[a]="");for(var s in t)a=t[s],t.hasOwnProperty(s)&&l[s]!==a&&Ru(e,s,a)}else for(var r in t)t.hasOwnProperty(r)&&Ru(e,r,t[r])}function wc(e){if(e.indexOf("-")===-1)return!1;switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Jp=new Map([["acceptCharset","accept-charset"],["htmlFor","for"],["httpEquiv","http-equiv"],["crossOrigin","crossorigin"],["accentHeight","accent-height"],["alignmentBaseline","alignment-baseline"],["arabicForm","arabic-form"],["baselineShift","baseline-shift"],["capHeight","cap-height"],["clipPath","clip-path"],["clipRule","clip-rule"],["colorInterpolation","color-interpolation"],["colorInterpolationFilters","color-interpolation-filters"],["colorProfile","color-profile"],["colorRendering","color-rendering"],["dominantBaseline","dominant-baseline"],["enableBackground","enable-background"],["fillOpacity","fill-opacity"],["fillRule","fill-rule"],["floodColor","flood-color"],["floodOpacity","flood-opacity"],["fontFamily","font-family"],["fontSize","font-size"],["fontSizeAdjust","font-size-adjust"],["fontStretch","font-stretch"],["fontStyle","font-style"],["fontVariant","font-variant"],["fontWeight","font-weight"],["glyphName","glyph-name"],["glyphOrientationHorizontal","glyph-orientation-horizontal"],["glyphOrientationVertical","glyph-orientation-vertical"],["horizAdvX","horiz-adv-x"],["horizOriginX","horiz-origin-x"],["imageRendering","image-rendering"],["letterSpacing","letter-spacing"],["lightingColor","lighting-color"],["markerEnd","marker-end"],["markerMid","marker-mid"],["markerStart","marker-start"],["overlinePosition","overline-position"],["overlineThickness","overline-thickness"],["paintOrder","paint-order"],["panose-1","panose-1"],["pointerEvents","pointer-events"],["renderingIntent","rendering-intent"],["shapeRendering","shape-rendering"],["stopColor","stop-color"],["stopOpacity","stop-opacity"],["strikethroughPosition","strikethrough-position"],["strikethroughThickness","strikethrough-thickness"],["strokeDasharray","stroke-dasharray"],["strokeDashoffset","stroke-dashoffset"],["strokeLinecap","stroke-linecap"],["strokeLinejoin","stroke-linejoin"],["strokeMiterlimit","stroke-miterlimit"],["strokeOpacity","stroke-opacity"],["strokeWidth","stroke-width"],["textAnchor","text-anchor"],["textDecoration","text-decoration"],["textRendering","text-rendering"],["transformOrigin","transform-origin"],["underlinePosition","underline-position"],["underlineThickness","underline-thickness"],["unicodeBidi","unicode-bidi"],["unicodeRange","unicode-range"],["unitsPerEm","units-per-em"],["vAlphabetic","v-alphabetic"],["vHanging","v-hanging"],["vIdeographic","v-ideographic"],["vMathematical","v-mathematical"],["vectorEffect","vector-effect"],["vertAdvY","vert-adv-y"],["vertOriginX","vert-origin-x"],["vertOriginY","vert-origin-y"],["wordSpacing","word-spacing"],["writingMode","writing-mode"],["xmlnsXlink","xmlns:xlink"],["xHeight","x-height"]]),Wp=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;function Fs(e){return Wp.test(""+e)?"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')":e}function gl(){}var Tc=null;function Ac(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Qa=null,Xa=null;function Hu(e){var t=Ba(e);if(t&&(e=t.stateNode)){var l=e[jt]||null;e:switch(e=t.stateNode,t.type){case"input":if(zc(e,l.value,l.defaultValue,l.defaultValue,l.checked,l.defaultChecked,l.type,l.name),t=l.name,l.type==="radio"&&t!=null){for(l=e;l.parentNode;)l=l.parentNode;for(l=l.querySelectorAll('input[name="'+Bt(""+t)+'"][type="radio"]'),t=0;t<l.length;t++){var a=l[t];if(a!==e&&a.form===e.form){var s=a[jt]||null;if(!s)throw Error(u(90));zc(a,s.value,s.defaultValue,s.defaultValue,s.checked,s.defaultChecked,s.type,s.name)}}for(t=0;t<l.length;t++)a=l[t],a.form===e.form&&Mu(a)}break e;case"textarea":Ou(e,l.value,l.defaultValue);break e;case"select":t=l.value,t!=null&&Ga(e,!!l.multiple,t,!1)}}}var _c=!1;function Lu(e,t,l){if(_c)return e(t,l);_c=!0;try{var a=e(t);return a}finally{if(_c=!1,(Qa!==null||Xa!==null)&&(Li(),Qa&&(t=Qa,e=Xa,Xa=Qa=null,Hu(t),e)))for(t=0;t<e.length;t++)Hu(e[t])}}function Hn(e,t){var l=e.stateNode;if(l===null)return null;var a=l[jt]||null;if(a===null)return null;l=a[t];e:switch(t){case"onClick":case"onClickCapture":case"onDoubleClick":case"onDoubleClickCapture":case"onMouseDown":case"onMouseDownCapture":case"onMouseMove":case"onMouseMoveCapture":case"onMouseUp":case"onMouseUpCapture":case"onMouseEnter":(a=!a.disabled)||(e=e.type,a=!(e==="button"||e==="input"||e==="select"||e==="textarea")),e=!a;break e;default:e=!1}if(e)return null;if(l&&typeof l!="function")throw Error(u(231,t,typeof l));return l}var bl=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Cc=!1;if(bl)try{var Ln={};Object.defineProperty(Ln,"passive",{get:function(){Cc=!0}}),window.addEventListener("test",Ln,Ln),window.removeEventListener("test",Ln,Ln)}catch{Cc=!1}var Ll=null,Mc=null,Is=null;function Bu(){if(Is)return Is;var e,t=Mc,l=t.length,a,s="value"in Ll?Ll.value:Ll.textContent,r=s.length;for(e=0;e<l&&t[e]===s[e];e++);var m=l-e;for(a=1;a<=m&&t[l-a]===s[r-a];a++);return Is=s.slice(e,1<a?1-a:void 0)}function Ps(e){var t=e.keyCode;return"charCode"in e?(e=e.charCode,e===0&&t===13&&(e=13)):e=t,e===10&&(e=13),32<=e||e===13?e:0}function ei(){return!0}function qu(){return!1}function Nt(e){function t(l,a,s,r,m){this._reactName=l,this._targetInst=s,this.type=a,this.nativeEvent=r,this.target=m,this.currentTarget=null;for(var b in e)e.hasOwnProperty(b)&&(l=e[b],this[b]=l?l(r):r[b]);return this.isDefaultPrevented=(r.defaultPrevented!=null?r.defaultPrevented:r.returnValue===!1)?ei:qu,this.isPropagationStopped=qu,this}return y(t.prototype,{preventDefault:function(){this.defaultPrevented=!0;var l=this.nativeEvent;l&&(l.preventDefault?l.preventDefault():typeof l.returnValue!="unknown"&&(l.returnValue=!1),this.isDefaultPrevented=ei)},stopPropagation:function(){var l=this.nativeEvent;l&&(l.stopPropagation?l.stopPropagation():typeof l.cancelBubble!="unknown"&&(l.cancelBubble=!0),this.isPropagationStopped=ei)},persist:function(){},isPersistent:ei}),t}var pa={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},ti=Nt(pa),Bn=y({},pa,{view:0,detail:0}),Fp=Nt(Bn),kc,Oc,qn,li=y({},Bn,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Rc,button:0,buttons:0,relatedTarget:function(e){return e.relatedTarget===void 0?e.fromElement===e.srcElement?e.toElement:e.fromElement:e.relatedTarget},movementX:function(e){return"movementX"in e?e.movementX:(e!==qn&&(qn&&e.type==="mousemove"?(kc=e.screenX-qn.screenX,Oc=e.screenY-qn.screenY):Oc=kc=0,qn=e),kc)},movementY:function(e){return"movementY"in e?e.movementY:Oc}}),Yu=Nt(li),Ip=y({},li,{dataTransfer:0}),Pp=Nt(Ip),ex=y({},Bn,{relatedTarget:0}),Dc=Nt(ex),tx=y({},pa,{animationName:0,elapsedTime:0,pseudoElement:0}),lx=Nt(tx),ax=y({},pa,{clipboardData:function(e){return"clipboardData"in e?e.clipboardData:window.clipboardData}}),nx=Nt(ax),sx=y({},pa,{data:0}),Gu=Nt(sx),ix={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},cx={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},rx={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"};function ox(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):(e=rx[e])?!!t[e]:!1}function Rc(){return ox}var ux=y({},Bn,{key:function(e){if(e.key){var t=ix[e.key]||e.key;if(t!=="Unidentified")return t}return e.type==="keypress"?(e=Ps(e),e===13?"Enter":String.fromCharCode(e)):e.type==="keydown"||e.type==="keyup"?cx[e.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:Rc,charCode:function(e){return e.type==="keypress"?Ps(e):0},keyCode:function(e){return e.type==="keydown"||e.type==="keyup"?e.keyCode:0},which:function(e){return e.type==="keypress"?Ps(e):e.type==="keydown"||e.type==="keyup"?e.keyCode:0}}),fx=Nt(ux),dx=y({},li,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),$u=Nt(dx),mx=y({},Bn,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:Rc}),hx=Nt(mx),px=y({},pa,{propertyName:0,elapsedTime:0,pseudoElement:0}),xx=Nt(px),gx=y({},li,{deltaX:function(e){return"deltaX"in e?e.deltaX:"wheelDeltaX"in e?-e.wheelDeltaX:0},deltaY:function(e){return"deltaY"in e?e.deltaY:"wheelDeltaY"in e?-e.wheelDeltaY:"wheelDelta"in e?-e.wheelDelta:0},deltaZ:0,deltaMode:0}),bx=Nt(gx),vx=y({},pa,{newState:0,oldState:0}),yx=Nt(vx),jx=[9,13,27,32],Uc=bl&&"CompositionEvent"in window,Yn=null;bl&&"documentMode"in document&&(Yn=document.documentMode);var Nx=bl&&"TextEvent"in window&&!Yn,Qu=bl&&(!Uc||Yn&&8<Yn&&11>=Yn),Xu=" ",Vu=!1;function Zu(e,t){switch(e){case"keyup":return jx.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ku(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Va=!1;function Sx(e,t){switch(e){case"compositionend":return Ku(t);case"keypress":return t.which!==32?null:(Vu=!0,Xu);case"textInput":return e=t.data,e===Xu&&Vu?null:e;default:return null}}function zx(e,t){if(Va)return e==="compositionend"||!Uc&&Zu(e,t)?(e=Bu(),Is=Mc=Ll=null,Va=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case"compositionend":return Qu&&t.locale!=="ko"?null:t.data;default:return null}}var Ex={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Ju(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t==="input"?!!Ex[e.type]:t==="textarea"}function Wu(e,t,l,a){Qa?Xa?Xa.push(a):Xa=[a]:Qa=a,t=Xi(t,"onChange"),0<t.length&&(l=new ti("onChange","change",null,l,a),e.push({event:l,listeners:t}))}var Gn=null,$n=null;function wx(e){Om(e,0)}function ai(e){var t=Un(e);if(Mu(t))return e}function Fu(e,t){if(e==="change")return t}var Iu=!1;if(bl){var Hc;if(bl){var Lc="oninput"in document;if(!Lc){var Pu=document.createElement("div");Pu.setAttribute("oninput","return;"),Lc=typeof Pu.oninput=="function"}Hc=Lc}else Hc=!1;Iu=Hc&&(!document.documentMode||9<document.documentMode)}function ef(){Gn&&(Gn.detachEvent("onpropertychange",tf),$n=Gn=null)}function tf(e){if(e.propertyName==="value"&&ai($n)){var t=[];Wu(t,$n,e,Ac(e)),Lu(wx,t)}}function Tx(e,t,l){e==="focusin"?(ef(),Gn=t,$n=l,Gn.attachEvent("onpropertychange",tf)):e==="focusout"&&ef()}function Ax(e){if(e==="selectionchange"||e==="keyup"||e==="keydown")return ai($n)}function _x(e,t){if(e==="click")return ai(t)}function Cx(e,t){if(e==="input"||e==="change")return ai(t)}function Mx(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Ct=typeof Object.is=="function"?Object.is:Mx;function Qn(e,t){if(Ct(e,t))return!0;if(typeof e!="object"||e===null||typeof t!="object"||t===null)return!1;var l=Object.keys(e),a=Object.keys(t);if(l.length!==a.length)return!1;for(a=0;a<l.length;a++){var s=l[a];if(!Ht.call(t,s)||!Ct(e[s],t[s]))return!1}return!0}function lf(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function af(e,t){var l=lf(e);e=0;for(var a;l;){if(l.nodeType===3){if(a=e+l.textContent.length,e<=t&&a>=t)return{node:l,offset:t-e};e=a}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=lf(l)}}function nf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?nf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function sf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ws(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=Ws(e.document)}return t}function Bc(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var kx=bl&&"documentMode"in document&&11>=document.documentMode,Za=null,qc=null,Xn=null,Yc=!1;function cf(e,t,l){var a=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Yc||Za==null||Za!==Ws(a)||(a=Za,"selectionStart"in a&&Bc(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Xn&&Qn(Xn,a)||(Xn=a,a=Xi(qc,"onSelect"),0<a.length&&(t=new ti("onSelect","select",null,t,l),e.push({event:t,listeners:a}),t.target=Za)))}function xa(e,t){var l={};return l[e.toLowerCase()]=t.toLowerCase(),l["Webkit"+e]="webkit"+t,l["Moz"+e]="moz"+t,l}var Ka={animationend:xa("Animation","AnimationEnd"),animationiteration:xa("Animation","AnimationIteration"),animationstart:xa("Animation","AnimationStart"),transitionrun:xa("Transition","TransitionRun"),transitionstart:xa("Transition","TransitionStart"),transitioncancel:xa("Transition","TransitionCancel"),transitionend:xa("Transition","TransitionEnd")},Gc={},rf={};bl&&(rf=document.createElement("div").style,"AnimationEvent"in window||(delete Ka.animationend.animation,delete Ka.animationiteration.animation,delete Ka.animationstart.animation),"TransitionEvent"in window||delete Ka.transitionend.transition);function ga(e){if(Gc[e])return Gc[e];if(!Ka[e])return e;var t=Ka[e],l;for(l in t)if(t.hasOwnProperty(l)&&l in rf)return Gc[e]=t[l];return e}var of=ga("animationend"),uf=ga("animationiteration"),ff=ga("animationstart"),Ox=ga("transitionrun"),Dx=ga("transitionstart"),Rx=ga("transitioncancel"),df=ga("transitionend"),mf=new Map,$c="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");$c.push("scrollEnd");function Ft(e,t){mf.set(e,t),ha(t,[e])}var ni=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)},qt=[],Ja=0,Qc=0;function si(){for(var e=Ja,t=Qc=Ja=0;t<e;){var l=qt[t];qt[t++]=null;var a=qt[t];qt[t++]=null;var s=qt[t];qt[t++]=null;var r=qt[t];if(qt[t++]=null,a!==null&&s!==null){var m=a.pending;m===null?s.next=s:(s.next=m.next,m.next=s),a.pending=s}r!==0&&hf(l,s,r)}}function ii(e,t,l,a){qt[Ja++]=e,qt[Ja++]=t,qt[Ja++]=l,qt[Ja++]=a,Qc|=a,e.lanes|=a,e=e.alternate,e!==null&&(e.lanes|=a)}function Xc(e,t,l,a){return ii(e,t,l,a),ci(e)}function ba(e,t){return ii(e,null,null,t),ci(e)}function hf(e,t,l){e.lanes|=l;var a=e.alternate;a!==null&&(a.lanes|=l);for(var s=!1,r=e.return;r!==null;)r.childLanes|=l,a=r.alternate,a!==null&&(a.childLanes|=l),r.tag===22&&(e=r.stateNode,e===null||e._visibility&1||(s=!0)),e=r,r=r.return;return e.tag===3?(r=e.stateNode,s&&t!==null&&(s=31-xt(l),e=r.hiddenUpdates,a=e[s],a===null?e[s]=[t]:a.push(t),t.lane=l|536870912),r):null}function ci(e){if(50<ms)throw ms=0,eo=null,Error(u(185));for(var t=e.return;t!==null;)e=t,t=e.return;return e.tag===3?e.stateNode:null}var Wa={};function Ux(e,t,l,a){this.tag=e,this.key=l,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Mt(e,t,l,a){return new Ux(e,t,l,a)}function Vc(e){return e=e.prototype,!(!e||!e.isReactComponent)}function vl(e,t){var l=e.alternate;return l===null?(l=Mt(e.tag,t,e.key,e.mode),l.elementType=e.elementType,l.type=e.type,l.stateNode=e.stateNode,l.alternate=e,e.alternate=l):(l.pendingProps=t,l.type=e.type,l.flags=0,l.subtreeFlags=0,l.deletions=null),l.flags=e.flags&65011712,l.childLanes=e.childLanes,l.lanes=e.lanes,l.child=e.child,l.memoizedProps=e.memoizedProps,l.memoizedState=e.memoizedState,l.updateQueue=e.updateQueue,t=e.dependencies,l.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},l.sibling=e.sibling,l.index=e.index,l.ref=e.ref,l.refCleanup=e.refCleanup,l}function pf(e,t){e.flags&=65011714;var l=e.alternate;return l===null?(e.childLanes=0,e.lanes=t,e.child=null,e.subtreeFlags=0,e.memoizedProps=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.stateNode=null):(e.childLanes=l.childLanes,e.lanes=l.lanes,e.child=l.child,e.subtreeFlags=0,e.deletions=null,e.memoizedProps=l.memoizedProps,e.memoizedState=l.memoizedState,e.updateQueue=l.updateQueue,e.type=l.type,t=l.dependencies,e.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext}),e}function ri(e,t,l,a,s,r){var m=0;if(a=e,typeof e=="function")Vc(e)&&(m=1);else if(typeof e=="string")m=Yg(e,l,J.current)?26:e==="html"||e==="head"||e==="body"?27:5;else e:switch(e){case se:return e=Mt(31,l,t,s),e.elementType=se,e.lanes=r,e;case A:return va(l.children,s,r,t);case D:m=8,s|=24;break;case U:return e=Mt(12,l,t,s|2),e.elementType=U,e.lanes=r,e;case $:return e=Mt(13,l,t,s),e.elementType=$,e.lanes=r,e;case K:return e=Mt(19,l,t,s),e.elementType=K,e.lanes=r,e;default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Z:m=10;break e;case X:m=9;break e;case P:m=11;break e;case G:m=14;break e;case ee:m=16,a=null;break e}m=29,l=Error(u(130,e===null?"null":typeof e,"")),a=null}return t=Mt(m,l,t,s),t.elementType=e,t.type=a,t.lanes=r,t}function va(e,t,l,a){return e=Mt(7,e,a,t),e.lanes=l,e}function Zc(e,t,l){return e=Mt(6,e,null,t),e.lanes=l,e}function xf(e){var t=Mt(18,null,null,0);return t.stateNode=e,t}function Kc(e,t,l){return t=Mt(4,e.children!==null?e.children:[],e.key,t),t.lanes=l,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}var gf=new WeakMap;function Yt(e,t){if(typeof e=="object"&&e!==null){var l=gf.get(e);return l!==void 0?l:(t={value:e,source:t,stack:ll(t)},gf.set(e,t),t)}return{value:e,source:t,stack:ll(t)}}var Fa=[],Ia=0,oi=null,Vn=0,Gt=[],$t=0,Bl=null,il=1,cl="";function yl(e,t){Fa[Ia++]=Vn,Fa[Ia++]=oi,oi=e,Vn=t}function bf(e,t,l){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,Bl=e;var a=il;e=cl;var s=32-xt(a)-1;a&=~(1<<s),l+=1;var r=32-xt(t)+s;if(30<r){var m=s-s%5;r=(a&(1<<m)-1).toString(32),a>>=m,s-=m,il=1<<32-xt(t)+s|l<<s|a,cl=r+e}else il=1<<r|l<<s|a,cl=e}function Jc(e){e.return!==null&&(yl(e,1),bf(e,1,0))}function Wc(e){for(;e===oi;)oi=Fa[--Ia],Fa[Ia]=null,Vn=Fa[--Ia],Fa[Ia]=null;for(;e===Bl;)Bl=Gt[--$t],Gt[$t]=null,cl=Gt[--$t],Gt[$t]=null,il=Gt[--$t],Gt[$t]=null}function vf(e,t){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,il=t.id,cl=t.overflow,Bl=e}var ct=null,Le=null,ve=!1,ql=null,Qt=!1,Fc=Error(u(519));function Yl(e){var t=Error(u(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?"text":"HTML",""));throw Zn(Yt(t,e)),Fc}function yf(e){var t=e.stateNode,l=e.type,a=e.memoizedProps;switch(t[it]=e,t[jt]=a,l){case"dialog":xe("cancel",t),xe("close",t);break;case"iframe":case"object":case"embed":xe("load",t);break;case"video":case"audio":for(l=0;l<ps.length;l++)xe(ps[l],t);break;case"source":xe("error",t);break;case"img":case"image":case"link":xe("error",t),xe("load",t);break;case"details":xe("toggle",t);break;case"input":xe("invalid",t),ku(t,a.value,a.defaultValue,a.checked,a.defaultChecked,a.type,a.name,!0);break;case"select":xe("invalid",t);break;case"textarea":xe("invalid",t),Du(t,a.value,a.defaultValue,a.children)}l=a.children,typeof l!="string"&&typeof l!="number"&&typeof l!="bigint"||t.textContent===""+l||a.suppressHydrationWarning===!0||Hm(t.textContent,l)?(a.popover!=null&&(xe("beforetoggle",t),xe("toggle",t)),a.onScroll!=null&&xe("scroll",t),a.onScrollEnd!=null&&xe("scrollend",t),a.onClick!=null&&(t.onclick=gl),t=!0):t=!1,t||Yl(e,!0)}function jf(e){for(ct=e.return;ct;)switch(ct.tag){case 5:case 31:case 13:Qt=!1;return;case 27:case 3:Qt=!0;return;default:ct=ct.return}}function Pa(e){if(e!==ct)return!1;if(!ve)return jf(e),ve=!0,!1;var t=e.tag,l;if((l=t!==3&&t!==27)&&((l=t===5)&&(l=e.type,l=!(l!=="form"&&l!=="button")||xo(e.type,e.memoizedProps)),l=!l),l&&Le&&Yl(e),jf(e),t===13){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else if(t===31){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else t===27?(t=Le,ta(e.type)?(e=jo,jo=null,Le=e):Le=t):Le=ct?Vt(e.stateNode.nextSibling):null;return!0}function ya(){Le=ct=null,ve=!1}function Ic(){var e=ql;return e!==null&&(wt===null?wt=e:wt.push.apply(wt,e),ql=null),e}function Zn(e){ql===null?ql=[e]:ql.push(e)}var Pc=S(null),ja=null,jl=null;function Gl(e,t,l){V(Pc,t._currentValue),t._currentValue=l}function Nl(e){e._currentValue=Pc.current,L(Pc)}function er(e,t,l){for(;e!==null;){var a=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,a!==null&&(a.childLanes|=t)):a!==null&&(a.childLanes&t)!==t&&(a.childLanes|=t),e===l)break;e=e.return}}function tr(e,t,l,a){var s=e.child;for(s!==null&&(s.return=e);s!==null;){var r=s.dependencies;if(r!==null){var m=s.child;r=r.firstContext;e:for(;r!==null;){var b=r;r=s;for(var j=0;j<t.length;j++)if(b.context===t[j]){r.lanes|=l,b=r.alternate,b!==null&&(b.lanes|=l),er(r.return,l,e),a||(m=null);break e}r=b.next}}else if(s.tag===18){if(m=s.return,m===null)throw Error(u(341));m.lanes|=l,r=m.alternate,r!==null&&(r.lanes|=l),er(m,l,e),m=null}else m=s.child;if(m!==null)m.return=s;else for(m=s;m!==null;){if(m===e){m=null;break}if(s=m.sibling,s!==null){s.return=m.return,m=s;break}m=m.return}s=m}}function en(e,t,l,a){e=null;for(var s=t,r=!1;s!==null;){if(!r){if((s.flags&524288)!==0)r=!0;else if((s.flags&262144)!==0)break}if(s.tag===10){var m=s.alternate;if(m===null)throw Error(u(387));if(m=m.memoizedProps,m!==null){var b=s.type;Ct(s.pendingProps.value,m.value)||(e!==null?e.push(b):e=[b])}}else if(s===oe.current){if(m=s.alternate,m===null)throw Error(u(387));m.memoizedState.memoizedState!==s.memoizedState.memoizedState&&(e!==null?e.push(ys):e=[ys])}s=s.return}e!==null&&tr(t,e,l,a),t.flags|=262144}function ui(e){for(e=e.firstContext;e!==null;){if(!Ct(e.context._currentValue,e.memoizedValue))return!0;e=e.next}return!1}function Na(e){ja=e,jl=null,e=e.dependencies,e!==null&&(e.firstContext=null)}function rt(e){return Nf(ja,e)}function fi(e,t){return ja===null&&Na(e),Nf(e,t)}function Nf(e,t){var l=t._currentValue;if(t={context:t,memoizedValue:l,next:null},jl===null){if(e===null)throw Error(u(308));jl=t,e.dependencies={lanes:0,firstContext:t},e.flags|=524288}else jl=jl.next=t;return l}var Hx=typeof AbortController<"u"?AbortController:function(){var e=[],t=this.signal={aborted:!1,addEventListener:function(l,a){e.push(a)}};this.abort=function(){t.aborted=!0,e.forEach(function(l){return l()})}},Lx=i.unstable_scheduleCallback,Bx=i.unstable_NormalPriority,Fe={$$typeof:Z,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function lr(){return{controller:new Hx,data:new Map,refCount:0}}function Kn(e){e.refCount--,e.refCount===0&&Lx(Bx,function(){e.controller.abort()})}var Jn=null,ar=0,tn=0,ln=null;function qx(e,t){if(Jn===null){var l=Jn=[];ar=0,tn=io(),ln={status:"pending",value:void 0,then:function(a){l.push(a)}}}return ar++,t.then(Sf,Sf),t}function Sf(){if(--ar===0&&Jn!==null){ln!==null&&(ln.status="fulfilled");var e=Jn;Jn=null,tn=0,ln=null;for(var t=0;t<e.length;t++)(0,e[t])()}}function Yx(e,t){var l=[],a={status:"pending",value:null,reason:null,then:function(s){l.push(s)}};return e.then(function(){a.status="fulfilled",a.value=t;for(var s=0;s<l.length;s++)(0,l[s])(t)},function(s){for(a.status="rejected",a.reason=s,s=0;s<l.length;s++)(0,l[s])(void 0)}),a}var zf=k.S;k.S=function(e,t){cm=dt(),typeof t=="object"&&t!==null&&typeof t.then=="function"&&qx(e,t),zf!==null&&zf(e,t)};var Sa=S(null);function nr(){var e=Sa.current;return e!==null?e:Ue.pooledCache}function di(e,t){t===null?V(Sa,Sa.current):V(Sa,t.pool)}function Ef(){var e=nr();return e===null?null:{parent:Fe._currentValue,pool:e}}var an=Error(u(460)),sr=Error(u(474)),mi=Error(u(542)),hi={then:function(){}};function wf(e){return e=e.status,e==="fulfilled"||e==="rejected"}function Tf(e,t,l){switch(l=e[l],l===void 0?e.push(t):l!==t&&(t.then(gl,gl),t=l),t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,_f(e),e;default:if(typeof t.status=="string")t.then(gl,gl);else{if(e=Ue,e!==null&&100<e.shellSuspendCounter)throw Error(u(482));e=t,e.status="pending",e.then(function(a){if(t.status==="pending"){var s=t;s.status="fulfilled",s.value=a}},function(a){if(t.status==="pending"){var s=t;s.status="rejected",s.reason=a}})}switch(t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,_f(e),e}throw Ea=t,an}}function za(e){try{var t=e._init;return t(e._payload)}catch(l){throw l!==null&&typeof l=="object"&&typeof l.then=="function"?(Ea=l,an):l}}var Ea=null;function Af(){if(Ea===null)throw Error(u(459));var e=Ea;return Ea=null,e}function _f(e){if(e===an||e===mi)throw Error(u(483))}var nn=null,Wn=0;function pi(e){var t=Wn;return Wn+=1,nn===null&&(nn=[]),Tf(nn,e,t)}function Fn(e,t){t=t.props.ref,e.ref=t!==void 0?t:null}function xi(e,t){throw t.$$typeof===z?Error(u(525)):(e=Object.prototype.toString.call(t),Error(u(31,e==="[object Object]"?"object with keys {"+Object.keys(t).join(", ")+"}":e)))}function Cf(e){function t(T,E){if(e){var _=T.deletions;_===null?(T.deletions=[E],T.flags|=16):_.push(E)}}function l(T,E){if(!e)return null;for(;E!==null;)t(T,E),E=E.sibling;return null}function a(T){for(var E=new Map;T!==null;)T.key!==null?E.set(T.key,T):E.set(T.index,T),T=T.sibling;return E}function s(T,E){return T=vl(T,E),T.index=0,T.sibling=null,T}function r(T,E,_){return T.index=_,e?(_=T.alternate,_!==null?(_=_.index,_<E?(T.flags|=67108866,E):_):(T.flags|=67108866,E)):(T.flags|=1048576,E)}function m(T){return e&&T.alternate===null&&(T.flags|=67108866),T}function b(T,E,_,H){return E===null||E.tag!==6?(E=Zc(_,T.mode,H),E.return=T,E):(E=s(E,_),E.return=T,E)}function j(T,E,_,H){var ne=_.type;return ne===A?R(T,E,_.props.children,H,_.key):E!==null&&(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type)?(E=s(E,_.props),Fn(E,_),E.return=T,E):(E=ri(_.type,_.key,_.props,null,T.mode,H),Fn(E,_),E.return=T,E)}function C(T,E,_,H){return E===null||E.tag!==4||E.stateNode.containerInfo!==_.containerInfo||E.stateNode.implementation!==_.implementation?(E=Kc(_,T.mode,H),E.return=T,E):(E=s(E,_.children||[]),E.return=T,E)}function R(T,E,_,H,ne){return E===null||E.tag!==7?(E=va(_,T.mode,H,ne),E.return=T,E):(E=s(E,_),E.return=T,E)}function B(T,E,_){if(typeof E=="string"&&E!==""||typeof E=="number"||typeof E=="bigint")return E=Zc(""+E,T.mode,_),E.return=T,E;if(typeof E=="object"&&E!==null){switch(E.$$typeof){case w:return _=ri(E.type,E.key,E.props,null,T.mode,_),Fn(_,E),_.return=T,_;case N:return E=Kc(E,T.mode,_),E.return=T,E;case ee:return E=za(E),B(T,E,_)}if(ce(E)||ye(E))return E=va(E,T.mode,_,null),E.return=T,E;if(typeof E.then=="function")return B(T,pi(E),_);if(E.$$typeof===Z)return B(T,fi(T,E),_);xi(T,E)}return null}function M(T,E,_,H){var ne=E!==null?E.key:null;if(typeof _=="string"&&_!==""||typeof _=="number"||typeof _=="bigint")return ne!==null?null:b(T,E,""+_,H);if(typeof _=="object"&&_!==null){switch(_.$$typeof){case w:return _.key===ne?j(T,E,_,H):null;case N:return _.key===ne?C(T,E,_,H):null;case ee:return _=za(_),M(T,E,_,H)}if(ce(_)||ye(_))return ne!==null?null:R(T,E,_,H,null);if(typeof _.then=="function")return M(T,E,pi(_),H);if(_.$$typeof===Z)return M(T,E,fi(T,_),H);xi(T,_)}return null}function O(T,E,_,H,ne){if(typeof H=="string"&&H!==""||typeof H=="number"||typeof H=="bigint")return T=T.get(_)||null,b(E,T,""+H,ne);if(typeof H=="object"&&H!==null){switch(H.$$typeof){case w:return T=T.get(H.key===null?_:H.key)||null,j(E,T,H,ne);case N:return T=T.get(H.key===null?_:H.key)||null,C(E,T,H,ne);case ee:return H=za(H),O(T,E,_,H,ne)}if(ce(H)||ye(H))return T=T.get(_)||null,R(E,T,H,ne,null);if(typeof H.then=="function")return O(T,E,_,pi(H),ne);if(H.$$typeof===Z)return O(T,E,_,fi(E,H),ne);xi(E,H)}return null}function W(T,E,_,H){for(var ne=null,Ne=null,F=E,me=E=0,be=null;F!==null&&me<_.length;me++){F.index>me?(be=F,F=null):be=F.sibling;var Se=M(T,F,_[me],H);if(Se===null){F===null&&(F=be);break}e&&F&&Se.alternate===null&&t(T,F),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se,F=be}if(me===_.length)return l(T,F),ve&&yl(T,me),ne;if(F===null){for(;me<_.length;me++)F=B(T,_[me],H),F!==null&&(E=r(F,E,me),Ne===null?ne=F:Ne.sibling=F,Ne=F);return ve&&yl(T,me),ne}for(F=a(F);me<_.length;me++)be=O(F,T,me,_[me],H),be!==null&&(e&&be.alternate!==null&&F.delete(be.key===null?me:be.key),E=r(be,E,me),Ne===null?ne=be:Ne.sibling=be,Ne=be);return e&&F.forEach(function(ia){return t(T,ia)}),ve&&yl(T,me),ne}function ie(T,E,_,H){if(_==null)throw Error(u(151));for(var ne=null,Ne=null,F=E,me=E=0,be=null,Se=_.next();F!==null&&!Se.done;me++,Se=_.next()){F.index>me?(be=F,F=null):be=F.sibling;var ia=M(T,F,Se.value,H);if(ia===null){F===null&&(F=be);break}e&&F&&ia.alternate===null&&t(T,F),E=r(ia,E,me),Ne===null?ne=ia:Ne.sibling=ia,Ne=ia,F=be}if(Se.done)return l(T,F),ve&&yl(T,me),ne;if(F===null){for(;!Se.done;me++,Se=_.next())Se=B(T,Se.value,H),Se!==null&&(E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return ve&&yl(T,me),ne}for(F=a(F);!Se.done;me++,Se=_.next())Se=O(F,T,me,Se.value,H),Se!==null&&(e&&Se.alternate!==null&&F.delete(Se.key===null?me:Se.key),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return e&&F.forEach(function(Ig){return t(T,Ig)}),ve&&yl(T,me),ne}function Re(T,E,_,H){if(typeof _=="object"&&_!==null&&_.type===A&&_.key===null&&(_=_.props.children),typeof _=="object"&&_!==null){switch(_.$$typeof){case w:e:{for(var ne=_.key;E!==null;){if(E.key===ne){if(ne=_.type,ne===A){if(E.tag===7){l(T,E.sibling),H=s(E,_.props.children),H.return=T,T=H;break e}}else if(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type){l(T,E.sibling),H=s(E,_.props),Fn(H,_),H.return=T,T=H;break e}l(T,E);break}else t(T,E);E=E.sibling}_.type===A?(H=va(_.props.children,T.mode,H,_.key),H.return=T,T=H):(H=ri(_.type,_.key,_.props,null,T.mode,H),Fn(H,_),H.return=T,T=H)}return m(T);case N:e:{for(ne=_.key;E!==null;){if(E.key===ne)if(E.tag===4&&E.stateNode.containerInfo===_.containerInfo&&E.stateNode.implementation===_.implementation){l(T,E.sibling),H=s(E,_.children||[]),H.return=T,T=H;break e}else{l(T,E);break}else t(T,E);E=E.sibling}H=Kc(_,T.mode,H),H.return=T,T=H}return m(T);case ee:return _=za(_),Re(T,E,_,H)}if(ce(_))return W(T,E,_,H);if(ye(_)){if(ne=ye(_),typeof ne!="function")throw Error(u(150));return _=ne.call(_),ie(T,E,_,H)}if(typeof _.then=="function")return Re(T,E,pi(_),H);if(_.$$typeof===Z)return Re(T,E,fi(T,_),H);xi(T,_)}return typeof _=="string"&&_!==""||typeof _=="number"||typeof _=="bigint"?(_=""+_,E!==null&&E.tag===6?(l(T,E.sibling),H=s(E,_),H.return=T,T=H):(l(T,E),H=Zc(_,T.mode,H),H.return=T,T=H),m(T)):l(T,E)}return function(T,E,_,H){try{Wn=0;var ne=Re(T,E,_,H);return nn=null,ne}catch(F){if(F===an||F===mi)throw F;var Ne=Mt(29,F,null,T.mode);return Ne.lanes=H,Ne.return=T,Ne}finally{}}}var wa=Cf(!0),Mf=Cf(!1),$l=!1;function ir(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function cr(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ql(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Xl(e,t,l){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(we&2)!==0){var s=a.pending;return s===null?t.next=t:(t.next=s.next,s.next=t),a.pending=t,t=ci(e),hf(e,null,l),t}return ii(e,a,t,l),ci(e)}function In(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}function rr(e,t){var l=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,l===a)){var s=null,r=null;if(l=l.firstBaseUpdate,l!==null){do{var m={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};r===null?s=r=m:r=r.next=m,l=l.next}while(l!==null);r===null?s=r=t:r=r.next=t}else s=r=t;l={baseState:a.baseState,firstBaseUpdate:s,lastBaseUpdate:r,shared:a.shared,callbacks:a.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var or=!1;function Pn(){if(or){var e=ln;if(e!==null)throw e}}function es(e,t,l,a){or=!1;var s=e.updateQueue;$l=!1;var r=s.firstBaseUpdate,m=s.lastBaseUpdate,b=s.shared.pending;if(b!==null){s.shared.pending=null;var j=b,C=j.next;j.next=null,m===null?r=C:m.next=C,m=j;var R=e.alternate;R!==null&&(R=R.updateQueue,b=R.lastBaseUpdate,b!==m&&(b===null?R.firstBaseUpdate=C:b.next=C,R.lastBaseUpdate=j))}if(r!==null){var B=s.baseState;m=0,R=C=j=null,b=r;do{var M=b.lane&-536870913,O=M!==b.lane;if(O?(ge&M)===M:(a&M)===M){M!==0&&M===tn&&(or=!0),R!==null&&(R=R.next={lane:0,tag:b.tag,payload:b.payload,callback:null,next:null});e:{var W=e,ie=b;M=t;var Re=l;switch(ie.tag){case 1:if(W=ie.payload,typeof W=="function"){B=W.call(Re,B,M);break e}B=W;break e;case 3:W.flags=W.flags&-65537|128;case 0:if(W=ie.payload,M=typeof W=="function"?W.call(Re,B,M):W,M==null)break e;B=y({},B,M);break e;case 2:$l=!0}}M=b.callback,M!==null&&(e.flags|=64,O&&(e.flags|=8192),O=s.callbacks,O===null?s.callbacks=[M]:O.push(M))}else O={lane:M,tag:b.tag,payload:b.payload,callback:b.callback,next:null},R===null?(C=R=O,j=B):R=R.next=O,m|=M;if(b=b.next,b===null){if(b=s.shared.pending,b===null)break;O=b,b=O.next,O.next=null,s.lastBaseUpdate=O,s.shared.pending=null}}while(!0);R===null&&(j=B),s.baseState=j,s.firstBaseUpdate=C,s.lastBaseUpdate=R,r===null&&(s.shared.lanes=0),Wl|=m,e.lanes=m,e.memoizedState=B}}function kf(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function Of(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;e<l.length;e++)kf(l[e],t)}var sn=S(null),gi=S(0);function Df(e,t){e=Ml,V(gi,e),V(sn,t),Ml=e|t.baseLanes}function ur(){V(gi,Ml),V(sn,sn.current)}function fr(){Ml=gi.current,L(sn),L(gi)}var kt=S(null),Xt=null;function Vl(e){var t=e.alternate;V(Ke,Ke.current&1),V(kt,e),Xt===null&&(t===null||sn.current!==null||t.memoizedState!==null)&&(Xt=e)}function dr(e){V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)}function Rf(e){e.tag===22?(V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)):Zl()}function Zl(){V(Ke,Ke.current),V(kt,kt.current)}function Ot(e){L(kt),Xt===e&&(Xt=null),L(Ke)}var Ke=S(0);function bi(e){for(var t=e;t!==null;){if(t.tag===13){var l=t.memoizedState;if(l!==null&&(l=l.dehydrated,l===null||vo(l)||yo(l)))return t}else if(t.tag===19&&(t.memoizedProps.revealOrder==="forwards"||t.memoizedProps.revealOrder==="backwards"||t.memoizedProps.revealOrder==="unstable_legacy-backwards"||t.memoizedProps.revealOrder==="together")){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Sl=0,de=null,Oe=null,Ie=null,vi=!1,cn=!1,Ta=!1,yi=0,ts=0,rn=null,Gx=0;function Xe(){throw Error(u(321))}function mr(e,t){if(t===null)return!1;for(var l=0;l<t.length&&l<e.length;l++)if(!Ct(e[l],t[l]))return!1;return!0}function hr(e,t,l,a,s,r){return Sl=r,de=t,t.memoizedState=null,t.updateQueue=null,t.lanes=0,k.H=e===null||e.memoizedState===null?bd:_r,Ta=!1,r=l(a,s),Ta=!1,cn&&(r=Hf(t,l,a,s)),Uf(e),r}function Uf(e){k.H=ns;var t=Oe!==null&&Oe.next!==null;if(Sl=0,Ie=Oe=de=null,vi=!1,ts=0,rn=null,t)throw Error(u(300));e===null||Pe||(e=e.dependencies,e!==null&&ui(e)&&(Pe=!0))}function Hf(e,t,l,a){de=e;var s=0;do{if(cn&&(rn=null),ts=0,cn=!1,25<=s)throw Error(u(301));if(s+=1,Ie=Oe=null,e.updateQueue!=null){var r=e.updateQueue;r.lastEffect=null,r.events=null,r.stores=null,r.memoCache!=null&&(r.memoCache.index=0)}k.H=vd,r=t(l,a)}while(cn);return r}function $x(){var e=k.H,t=e.useState()[0];return t=typeof t.then=="function"?ls(t):t,e=e.useState()[0],(Oe!==null?Oe.memoizedState:null)!==e&&(de.flags|=1024),t}function pr(){var e=yi!==0;return yi=0,e}function xr(e,t,l){t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l}function gr(e){if(vi){for(e=e.memoizedState;e!==null;){var t=e.queue;t!==null&&(t.pending=null),e=e.next}vi=!1}Sl=0,Ie=Oe=de=null,cn=!1,ts=yi=0,rn=null}function gt(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e,Ie}function Je(){if(Oe===null){var e=de.alternate;e=e!==null?e.memoizedState:null}else e=Oe.next;var t=Ie===null?de.memoizedState:Ie.next;if(t!==null)Ie=t,Oe=e;else{if(e===null)throw de.alternate===null?Error(u(467)):Error(u(310));Oe=e,e={memoizedState:Oe.memoizedState,baseState:Oe.baseState,baseQueue:Oe.baseQueue,queue:Oe.queue,next:null},Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e}return Ie}function ji(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function ls(e){var t=ts;return ts+=1,rn===null&&(rn=[]),e=Tf(rn,e,t),t=de,(Ie===null?t.memoizedState:Ie.next)===null&&(t=t.alternate,k.H=t===null||t.memoizedState===null?bd:_r),e}function Ni(e){if(e!==null&&typeof e=="object"){if(typeof e.then=="function")return ls(e);if(e.$$typeof===Z)return rt(e)}throw Error(u(438,String(e)))}function br(e){var t=null,l=de.updateQueue;if(l!==null&&(t=l.memoCache),t==null){var a=de.alternate;a!==null&&(a=a.updateQueue,a!==null&&(a=a.memoCache,a!=null&&(t={data:a.data.map(function(s){return s.slice()}),index:0})))}if(t==null&&(t={data:[],index:0}),l===null&&(l=ji(),de.updateQueue=l),l.memoCache=t,l=t.data[t.index],l===void 0)for(l=t.data[t.index]=Array(e),a=0;a<e;a++)l[a]=Q;return t.index++,l}function zl(e,t){return typeof t=="function"?t(e):t}function Si(e){var t=Je();return vr(t,Oe,e)}function vr(e,t,l){var a=e.queue;if(a===null)throw Error(u(311));a.lastRenderedReducer=l;var s=e.baseQueue,r=a.pending;if(r!==null){if(s!==null){var m=s.next;s.next=r.next,r.next=m}t.baseQueue=s=r,a.pending=null}if(r=e.baseState,s===null)e.memoizedState=r;else{t=s.next;var b=m=null,j=null,C=t,R=!1;do{var B=C.lane&-536870913;if(B!==C.lane?(ge&B)===B:(Sl&B)===B){var M=C.revertLane;if(M===0)j!==null&&(j=j.next={lane:0,revertLane:0,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null}),B===tn&&(R=!0);else if((Sl&M)===M){C=C.next,M===tn&&(R=!0);continue}else B={lane:0,revertLane:C.revertLane,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=B,m=r):j=j.next=B,de.lanes|=M,Wl|=M;B=C.action,Ta&&l(r,B),r=C.hasEagerState?C.eagerState:l(r,B)}else M={lane:B,revertLane:C.revertLane,gesture:C.gesture,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=M,m=r):j=j.next=M,de.lanes|=B,Wl|=B;C=C.next}while(C!==null&&C!==t);if(j===null?m=r:j.next=b,!Ct(r,e.memoizedState)&&(Pe=!0,R&&(l=ln,l!==null)))throw l;e.memoizedState=r,e.baseState=m,e.baseQueue=j,a.lastRenderedState=r}return s===null&&(a.lanes=0),[e.memoizedState,a.dispatch]}function yr(e){var t=Je(),l=t.queue;if(l===null)throw Error(u(311));l.lastRenderedReducer=e;var a=l.dispatch,s=l.pending,r=t.memoizedState;if(s!==null){l.pending=null;var m=s=s.next;do r=e(r,m.action),m=m.next;while(m!==s);Ct(r,t.memoizedState)||(Pe=!0),t.memoizedState=r,t.baseQueue===null&&(t.baseState=r),l.lastRenderedState=r}return[r,a]}function Lf(e,t,l){var a=de,s=Je(),r=ve;if(r){if(l===void 0)throw Error(u(407));l=l()}else l=t();var m=!Ct((Oe||s).memoizedState,l);if(m&&(s.memoizedState=l,Pe=!0),s=s.queue,Sr(Yf.bind(null,a,s,e),[e]),s.getSnapshot!==t||m||Ie!==null&&Ie.memoizedState.tag&1){if(a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,s,l,t),null),Ue===null)throw Error(u(349));r||(Sl&127)!==0||Bf(a,t,l)}return l}function Bf(e,t,l){e.flags|=16384,e={getSnapshot:t,value:l},t=de.updateQueue,t===null?(t=ji(),de.updateQueue=t,t.stores=[e]):(l=t.stores,l===null?t.stores=[e]:l.push(e))}function qf(e,t,l,a){t.value=l,t.getSnapshot=a,Gf(t)&&$f(e)}function Yf(e,t,l){return l(function(){Gf(t)&&$f(e)})}function Gf(e){var t=e.getSnapshot;e=e.value;try{var l=t();return!Ct(e,l)}catch{return!0}}function $f(e){var t=ba(e,2);t!==null&&Tt(t,e,2)}function jr(e){var t=gt();if(typeof e=="function"){var l=e;if(e=l(),Ta){nl(!0);try{l()}finally{nl(!1)}}}return t.memoizedState=t.baseState=e,t.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:e},t}function Qf(e,t,l,a){return e.baseState=l,vr(e,Oe,typeof a=="function"?a:zl)}function Qx(e,t,l,a,s){if(wi(e))throw Error(u(485));if(e=t.action,e!==null){var r={payload:s,action:e,next:null,isTransition:!0,status:"pending",value:null,reason:null,listeners:[],then:function(m){r.listeners.push(m)}};k.T!==null?l(!0):r.isTransition=!1,a(r),l=t.pending,l===null?(r.next=t.pending=r,Xf(t,r)):(r.next=l.next,t.pending=l.next=r)}}function Xf(e,t){var l=t.action,a=t.payload,s=e.state;if(t.isTransition){var r=k.T,m={};k.T=m;try{var b=l(s,a),j=k.S;j!==null&&j(m,b),Vf(e,t,b)}catch(C){Nr(e,t,C)}finally{r!==null&&m.types!==null&&(r.types=m.types),k.T=r}}else try{r=l(s,a),Vf(e,t,r)}catch(C){Nr(e,t,C)}}function Vf(e,t,l){l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(function(a){Zf(e,t,a)},function(a){return Nr(e,t,a)}):Zf(e,t,l)}function Zf(e,t,l){t.status="fulfilled",t.value=l,Kf(t),e.state=l,t=e.pending,t!==null&&(l=t.next,l===t?e.pending=null:(l=l.next,t.next=l,Xf(e,l)))}function Nr(e,t,l){var a=e.pending;if(e.pending=null,a!==null){a=a.next;do t.status="rejected",t.reason=l,Kf(t),t=t.next;while(t!==a)}e.action=null}function Kf(e){e=e.listeners;for(var t=0;t<e.length;t++)(0,e[t])()}function Jf(e,t){return t}function Wf(e,t){if(ve){var l=Ue.formState;if(l!==null){e:{var a=de;if(ve){if(Le){t:{for(var s=Le,r=Qt;s.nodeType!==8;){if(!r){s=null;break t}if(s=Vt(s.nextSibling),s===null){s=null;break t}}r=s.data,s=r==="F!"||r==="F"?s:null}if(s){Le=Vt(s.nextSibling),a=s.data==="F!";break e}}Yl(a)}a=!1}a&&(t=l[0])}}return l=gt(),l.memoizedState=l.baseState=t,a={pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jf,lastRenderedState:t},l.queue=a,l=pd.bind(null,de,a),a.dispatch=l,a=jr(!1),r=Ar.bind(null,de,!1,a.queue),a=gt(),s={state:t,dispatch:null,action:e,pending:null},a.queue=s,l=Qx.bind(null,de,s,r,l),s.dispatch=l,a.memoizedState=e,[t,l,!1]}function Ff(e){var t=Je();return If(t,Oe,e)}function If(e,t,l){if(t=vr(e,t,Jf)[0],e=Si(zl)[0],typeof t=="object"&&t!==null&&typeof t.then=="function")try{var a=ls(t)}catch(m){throw m===an?mi:m}else a=t;t=Je();var s=t.queue,r=s.dispatch;return l!==t.memoizedState&&(de.flags|=2048,on(9,{destroy:void 0},Xx.bind(null,s,l),null)),[a,r,e]}function Xx(e,t){e.action=t}function Pf(e){var t=Je(),l=Oe;if(l!==null)return If(t,l,e);Je(),t=t.memoizedState,l=Je();var a=l.queue.dispatch;return l.memoizedState=e,[t,a,!1]}function on(e,t,l,a){return e={tag:e,create:l,deps:a,inst:t,next:null},t=de.updateQueue,t===null&&(t=ji(),de.updateQueue=t),l=t.lastEffect,l===null?t.lastEffect=e.next=e:(a=l.next,l.next=e,e.next=a,t.lastEffect=e),e}function ed(){return Je().memoizedState}function zi(e,t,l,a){var s=gt();de.flags|=e,s.memoizedState=on(1|t,{destroy:void 0},l,a===void 0?null:a)}function Ei(e,t,l,a){var s=Je();a=a===void 0?null:a;var r=s.memoizedState.inst;Oe!==null&&a!==null&&mr(a,Oe.memoizedState.deps)?s.memoizedState=on(t,r,l,a):(de.flags|=e,s.memoizedState=on(1|t,r,l,a))}function td(e,t){zi(8390656,8,e,t)}function Sr(e,t){Ei(2048,8,e,t)}function Vx(e){de.flags|=4;var t=de.updateQueue;if(t===null)t=ji(),de.updateQueue=t,t.events=[e];else{var l=t.events;l===null?t.events=[e]:l.push(e)}}function ld(e){var t=Je().memoizedState;return Vx({ref:t,nextImpl:e}),function(){if((we&2)!==0)throw Error(u(440));return t.impl.apply(void 0,arguments)}}function ad(e,t){return Ei(4,2,e,t)}function nd(e,t){return Ei(4,4,e,t)}function sd(e,t){if(typeof t=="function"){e=e();var l=t(e);return function(){typeof l=="function"?l():t(null)}}if(t!=null)return e=e(),t.current=e,function(){t.current=null}}function id(e,t,l){l=l!=null?l.concat([e]):null,Ei(4,4,sd.bind(null,t,e),l)}function zr(){}function cd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;return t!==null&&mr(t,a[1])?a[0]:(l.memoizedState=[e,t],e)}function rd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;if(t!==null&&mr(t,a[1]))return a[0];if(a=e(),Ta){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a}function Er(e,t,l){return l===void 0||(Sl&1073741824)!==0&&(ge&261930)===0?e.memoizedState=t:(e.memoizedState=l,e=om(),de.lanes|=e,Wl|=e,l)}function od(e,t,l,a){return Ct(l,t)?l:sn.current!==null?(e=Er(e,l,a),Ct(e,t)||(Pe=!0),e):(Sl&42)===0||(Sl&1073741824)!==0&&(ge&261930)===0?(Pe=!0,e.memoizedState=l):(e=om(),de.lanes|=e,Wl|=e,t)}function ud(e,t,l,a,s){var r=Y.p;Y.p=r!==0&&8>r?r:8;var m=k.T,b={};k.T=b,Ar(e,!1,t,l);try{var j=s(),C=k.S;if(C!==null&&C(b,j),j!==null&&typeof j=="object"&&typeof j.then=="function"){var R=Yx(j,a);as(e,t,R,Ut(e))}else as(e,t,a,Ut(e))}catch(B){as(e,t,{then:function(){},status:"rejected",reason:B},Ut())}finally{Y.p=r,m!==null&&b.types!==null&&(m.types=b.types),k.T=m}}function Zx(){}function wr(e,t,l,a){if(e.tag!==5)throw Error(u(476));var s=fd(e).queue;ud(e,s,t,te,l===null?Zx:function(){return dd(e),l(a)})}function fd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:te,baseState:te,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:te},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function dd(e){var t=fd(e);t.next===null&&(t=e.alternate.memoizedState),as(e,t.next.queue,{},Ut())}function Tr(){return rt(ys)}function md(){return Je().memoizedState}function hd(){return Je().memoizedState}function Kx(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Ut();e=Ql(l);var a=Xl(t,e,l);a!==null&&(Tt(a,t,l),In(a,t,l)),t={cache:lr()},e.payload=t;return}t=t.return}}function Jx(e,t,l){var a=Ut();l={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},wi(e)?xd(t,l):(l=Xc(e,t,l,a),l!==null&&(Tt(l,e,a),gd(l,t,a)))}function pd(e,t,l){var a=Ut();as(e,t,l,a)}function as(e,t,l,a){var s={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(wi(e))xd(t,s);else{var r=e.alternate;if(e.lanes===0&&(r===null||r.lanes===0)&&(r=t.lastRenderedReducer,r!==null))try{var m=t.lastRenderedState,b=r(m,l);if(s.hasEagerState=!0,s.eagerState=b,Ct(b,m))return ii(e,t,s,0),Ue===null&&si(),!1}catch{}finally{}if(l=Xc(e,t,s,a),l!==null)return Tt(l,e,a),gd(l,t,a),!0}return!1}function Ar(e,t,l,a){if(a={lane:2,revertLane:io(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},wi(e)){if(t)throw Error(u(479))}else t=Xc(e,l,a,2),t!==null&&Tt(t,e,2)}function wi(e){var t=e.alternate;return e===de||t!==null&&t===de}function xd(e,t){cn=vi=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function gd(e,t,l){if((l&4194048)!==0){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}var ns={readContext:rt,use:Ni,useCallback:Xe,useContext:Xe,useEffect:Xe,useImperativeHandle:Xe,useLayoutEffect:Xe,useInsertionEffect:Xe,useMemo:Xe,useReducer:Xe,useRef:Xe,useState:Xe,useDebugValue:Xe,useDeferredValue:Xe,useTransition:Xe,useSyncExternalStore:Xe,useId:Xe,useHostTransitionStatus:Xe,useFormState:Xe,useActionState:Xe,useOptimistic:Xe,useMemoCache:Xe,useCacheRefresh:Xe};ns.useEffectEvent=Xe;var bd={readContext:rt,use:Ni,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:rt,useEffect:td,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,zi(4194308,4,sd.bind(null,t,e),l)},useLayoutEffect:function(e,t){return zi(4194308,4,e,t)},useInsertionEffect:function(e,t){zi(4,2,e,t)},useMemo:function(e,t){var l=gt();t=t===void 0?null:t;var a=e();if(Ta){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a},useReducer:function(e,t,l){var a=gt();if(l!==void 0){var s=l(t);if(Ta){nl(!0);try{l(t)}finally{nl(!1)}}}else s=t;return a.memoizedState=a.baseState=s,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:s},a.queue=e,e=e.dispatch=Jx.bind(null,de,e),[a.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:function(e){e=jr(e);var t=e.queue,l=pd.bind(null,de,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:zr,useDeferredValue:function(e,t){var l=gt();return Er(l,e,t)},useTransition:function(){var e=jr(!1);return e=ud.bind(null,de,e.queue,!0,!1),gt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var a=de,s=gt();if(ve){if(l===void 0)throw Error(u(407));l=l()}else{if(l=t(),Ue===null)throw Error(u(349));(ge&127)!==0||Bf(a,t,l)}s.memoizedState=l;var r={value:l,getSnapshot:t};return s.queue=r,td(Yf.bind(null,a,r,e),[e]),a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,r,l,t),null),l},useId:function(){var e=gt(),t=Ue.identifierPrefix;if(ve){var l=cl,a=il;l=(a&~(1<<32-xt(a)-1)).toString(32)+l,t="_"+t+"R_"+l,l=yi++,0<l&&(t+="H"+l.toString(32)),t+="_"}else l=Gx++,t="_"+t+"r_"+l.toString(32)+"_";return e.memoizedState=t},useHostTransitionStatus:Tr,useFormState:Wf,useActionState:Wf,useOptimistic:function(e){var t=gt();t.memoizedState=t.baseState=e;var l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return t.queue=l,t=Ar.bind(null,de,!0,l),l.dispatch=t,[e,t]},useMemoCache:br,useCacheRefresh:function(){return gt().memoizedState=Kx.bind(null,de)},useEffectEvent:function(e){var t=gt(),l={impl:e};return t.memoizedState=l,function(){if((we&2)!==0)throw Error(u(440));return l.impl.apply(void 0,arguments)}}},_r={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:Si,useRef:ed,useState:function(){return Si(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=Si(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:Tr,useFormState:Ff,useActionState:Ff,useOptimistic:function(e,t){var l=Je();return Qf(l,Oe,e,t)},useMemoCache:br,useCacheRefresh:hd};_r.useEffectEvent=ld;var vd={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:yr,useRef:ed,useState:function(){return yr(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return Oe===null?Er(l,e,t):od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=yr(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:Tr,useFormState:Pf,useActionState:Pf,useOptimistic:function(e,t){var l=Je();return Oe!==null?Qf(l,Oe,e,t):(l.baseState=e,[e,l.queue.dispatch])},useMemoCache:br,useCacheRefresh:hd};vd.useEffectEvent=ld;function Cr(e,t,l,a){t=e.memoizedState,l=l(a,t),l=l==null?t:y({},t,l),e.memoizedState=l,e.lanes===0&&(e.updateQueue.baseState=l)}var Mr={enqueueSetState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(Tt(t,e,a),In(t,e,a))},enqueueReplaceState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.tag=1,s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(Tt(t,e,a),In(t,e,a))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var l=Ut(),a=Ql(l);a.tag=2,t!=null&&(a.callback=t),t=Xl(e,a,l),t!==null&&(Tt(t,e,l),In(t,e,l))}};function yd(e,t,l,a,s,r,m){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(a,r,m):t.prototype&&t.prototype.isPureReactComponent?!Qn(l,a)||!Qn(s,r):!0}function jd(e,t,l,a){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(l,a),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(l,a),t.state!==e&&Mr.enqueueReplaceState(t,t.state,null)}function Aa(e,t){var l=t;if("ref"in t){l={};for(var a in t)a!=="ref"&&(l[a]=t[a])}if(e=e.defaultProps){l===t&&(l=y({},l));for(var s in e)l[s]===void 0&&(l[s]=e[s])}return l}function Nd(e){ni(e)}function Sd(e){console.error(e)}function zd(e){ni(e)}function Ti(e,t){try{var l=e.onUncaughtError;l(t.value,{componentStack:t.stack})}catch(a){setTimeout(function(){throw a})}}function Ed(e,t,l){try{var a=e.onCaughtError;a(l.value,{componentStack:l.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(s){setTimeout(function(){throw s})}}function kr(e,t,l){return l=Ql(l),l.tag=3,l.payload={element:null},l.callback=function(){Ti(e,t)},l}function wd(e){return e=Ql(e),e.tag=3,e}function Td(e,t,l,a){var s=l.type.getDerivedStateFromError;if(typeof s=="function"){var r=a.value;e.payload=function(){return s(r)},e.callback=function(){Ed(t,l,a)}}var m=l.stateNode;m!==null&&typeof m.componentDidCatch=="function"&&(e.callback=function(){Ed(t,l,a),typeof s!="function"&&(Fl===null?Fl=new Set([this]):Fl.add(this));var b=a.stack;this.componentDidCatch(a.value,{componentStack:b!==null?b:""})})}function Wx(e,t,l,a,s){if(l.flags|=32768,a!==null&&typeof a=="object"&&typeof a.then=="function"){if(t=l.alternate,t!==null&&en(t,l,s,!0),l=kt.current,l!==null){switch(l.tag){case 31:case 13:return Xt===null?Bi():l.alternate===null&&Ve===0&&(Ve=3),l.flags&=-257,l.flags|=65536,l.lanes=s,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?l.updateQueue=new Set([a]):t.add(a),ao(e,a,s)),!1;case 22:return l.flags|=65536,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([a])},l.updateQueue=t):(l=t.retryQueue,l===null?t.retryQueue=new Set([a]):l.add(a)),ao(e,a,s)),!1}throw Error(u(435,l.tag))}return ao(e,a,s),Bi(),!1}if(ve)return t=kt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=s,a!==Fc&&(e=Error(u(422),{cause:a}),Zn(Yt(e,l)))):(a!==Fc&&(t=Error(u(423),{cause:a}),Zn(Yt(t,l))),e=e.current.alternate,e.flags|=65536,s&=-s,e.lanes|=s,a=Yt(a,l),s=kr(e.stateNode,a,s),rr(e,s),Ve!==4&&(Ve=2)),!1;var r=Error(u(520),{cause:a});if(r=Yt(r,l),ds===null?ds=[r]:ds.push(r),Ve!==4&&(Ve=2),t===null)return!0;a=Yt(a,l),l=t;do{switch(l.tag){case 3:return l.flags|=65536,e=s&-s,l.lanes|=e,e=kr(l.stateNode,a,e),rr(l,e),!1;case 1:if(t=l.type,r=l.stateNode,(l.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||r!==null&&typeof r.componentDidCatch=="function"&&(Fl===null||!Fl.has(r))))return l.flags|=65536,s&=-s,l.lanes|=s,s=wd(s),Td(s,e,l,a),rr(l,s),!1}l=l.return}while(l!==null);return!1}var Or=Error(u(461)),Pe=!1;function ot(e,t,l,a){t.child=e===null?Mf(t,null,l,a):wa(t,e.child,l,a)}function Ad(e,t,l,a,s){l=l.render;var r=t.ref;if("ref"in a){var m={};for(var b in a)b!=="ref"&&(m[b]=a[b])}else m=a;return Na(t),a=hr(e,t,l,m,r,s),b=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&b&&Jc(t),t.flags|=1,ot(e,t,a,s),t.child)}function _d(e,t,l,a,s){if(e===null){var r=l.type;return typeof r=="function"&&!Vc(r)&&r.defaultProps===void 0&&l.compare===null?(t.tag=15,t.type=r,Cd(e,t,r,a,s)):(e=ri(l.type,null,a,t,t.mode,s),e.ref=t.ref,e.return=t,t.child=e)}if(r=e.child,!Yr(e,s)){var m=r.memoizedProps;if(l=l.compare,l=l!==null?l:Qn,l(m,a)&&e.ref===t.ref)return El(e,t,s)}return t.flags|=1,e=vl(r,a),e.ref=t.ref,e.return=t,t.child=e}function Cd(e,t,l,a,s){if(e!==null){var r=e.memoizedProps;if(Qn(r,a)&&e.ref===t.ref)if(Pe=!1,t.pendingProps=a=r,Yr(e,s))(e.flags&131072)!==0&&(Pe=!0);else return t.lanes=e.lanes,El(e,t,s)}return Dr(e,t,l,a,s)}function Md(e,t,l,a){var s=a.children,r=e!==null?e.memoizedState:null;if(e===null&&t.stateNode===null&&(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),a.mode==="hidden"){if((t.flags&128)!==0){if(r=r!==null?r.baseLanes|l:l,e!==null){for(a=t.child=e.child,s=0;a!==null;)s=s|a.lanes|a.childLanes,a=a.sibling;a=s&~r}else a=0,t.child=null;return kd(e,t,r,l,a)}if((l&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&di(t,r!==null?r.cachePool:null),r!==null?Df(t,r):ur(),Rf(t);else return a=t.lanes=536870912,kd(e,t,r!==null?r.baseLanes|l:l,l,a)}else r!==null?(di(t,r.cachePool),Df(t,r),Zl(),t.memoizedState=null):(e!==null&&di(t,null),ur(),Zl());return ot(e,t,s,l),t.child}function ss(e,t){return e!==null&&e.tag===22||t.stateNode!==null||(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),t.sibling}function kd(e,t,l,a,s){var r=nr();return r=r===null?null:{parent:Fe._currentValue,pool:r},t.memoizedState={baseLanes:l,cachePool:r},e!==null&&di(t,null),ur(),Rf(t),e!==null&&en(e,t,a,!0),t.childLanes=s,null}function Ai(e,t){return t=Ci({mode:t.mode,children:t.children},e.mode),t.ref=e.ref,e.child=t,t.return=e,t}function Od(e,t,l){return wa(t,e.child,null,l),e=Ai(t,t.pendingProps),e.flags|=2,Ot(t),t.memoizedState=null,e}function Fx(e,t,l){var a=t.pendingProps,s=(t.flags&128)!==0;if(t.flags&=-129,e===null){if(ve){if(a.mode==="hidden")return e=Ai(t,a),t.lanes=536870912,ss(null,e);if(dr(t),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data==="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return t.lanes=536870912,null}return Ai(t,a)}var r=e.memoizedState;if(r!==null){var m=r.dehydrated;if(dr(t),s)if(t.flags&256)t.flags&=-257,t=Od(e,t,l);else if(t.memoizedState!==null)t.child=e.child,t.flags|=128,t=null;else throw Error(u(558));else if(Pe||en(e,t,l,!1),s=(l&e.childLanes)!==0,Pe||s){if(a=Ue,a!==null&&(m=Nu(a,l),m!==0&&m!==r.retryLane))throw r.retryLane=m,ba(e,m),Tt(a,e,m),Or;Bi(),t=Od(e,t,l)}else e=r.treeContext,Le=Vt(m.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Ai(t,a),t.flags|=4096;return t}return e=vl(e.child,{mode:a.mode,children:a.children}),e.ref=t.ref,t.child=e,e.return=t,e}function _i(e,t){var l=t.ref;if(l===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof l!="function"&&typeof l!="object")throw Error(u(284));(e===null||e.ref!==l)&&(t.flags|=4194816)}}function Dr(e,t,l,a,s){return Na(t),l=hr(e,t,l,a,void 0,s),a=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,s),t.child)}function Dd(e,t,l,a,s,r){return Na(t),t.updateQueue=null,l=Hf(t,a,l,s),Uf(e),a=pr(),e!==null&&!Pe?(xr(e,t,r),El(e,t,r)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,r),t.child)}function Rd(e,t,l,a,s){if(Na(t),t.stateNode===null){var r=Wa,m=l.contextType;typeof m=="object"&&m!==null&&(r=rt(m)),r=new l(a,r),t.memoizedState=r.state!==null&&r.state!==void 0?r.state:null,r.updater=Mr,t.stateNode=r,r._reactInternals=t,r=t.stateNode,r.props=a,r.state=t.memoizedState,r.refs={},ir(t),m=l.contextType,r.context=typeof m=="object"&&m!==null?rt(m):Wa,r.state=t.memoizedState,m=l.getDerivedStateFromProps,typeof m=="function"&&(Cr(t,l,m,a),r.state=t.memoizedState),typeof l.getDerivedStateFromProps=="function"||typeof r.getSnapshotBeforeUpdate=="function"||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(m=r.state,typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount(),m!==r.state&&Mr.enqueueReplaceState(r,r.state,null),es(t,a,r,s),Pn(),r.state=t.memoizedState),typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!0}else if(e===null){r=t.stateNode;var b=t.memoizedProps,j=Aa(l,b);r.props=j;var C=r.context,R=l.contextType;m=Wa,typeof R=="object"&&R!==null&&(m=rt(R));var B=l.getDerivedStateFromProps;R=typeof B=="function"||typeof r.getSnapshotBeforeUpdate=="function",b=t.pendingProps!==b,R||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(b||C!==m)&&jd(t,r,a,m),$l=!1;var M=t.memoizedState;r.state=M,es(t,a,r,s),Pn(),C=t.memoizedState,b||M!==C||$l?(typeof B=="function"&&(Cr(t,l,B,a),C=t.memoizedState),(j=$l||yd(t,l,j,a,M,C,m))?(R||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount()),typeof r.componentDidMount=="function"&&(t.flags|=4194308)):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=a,t.memoizedState=C),r.props=a,r.state=C,r.context=m,a=j):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!1)}else{r=t.stateNode,cr(e,t),m=t.memoizedProps,R=Aa(l,m),r.props=R,B=t.pendingProps,M=r.context,C=l.contextType,j=Wa,typeof C=="object"&&C!==null&&(j=rt(C)),b=l.getDerivedStateFromProps,(C=typeof b=="function"||typeof r.getSnapshotBeforeUpdate=="function")||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(m!==B||M!==j)&&jd(t,r,a,j),$l=!1,M=t.memoizedState,r.state=M,es(t,a,r,s),Pn();var O=t.memoizedState;m!==B||M!==O||$l||e!==null&&e.dependencies!==null&&ui(e.dependencies)?(typeof b=="function"&&(Cr(t,l,b,a),O=t.memoizedState),(R=$l||yd(t,l,R,a,M,O,j)||e!==null&&e.dependencies!==null&&ui(e.dependencies))?(C||typeof r.UNSAFE_componentWillUpdate!="function"&&typeof r.componentWillUpdate!="function"||(typeof r.componentWillUpdate=="function"&&r.componentWillUpdate(a,O,j),typeof r.UNSAFE_componentWillUpdate=="function"&&r.UNSAFE_componentWillUpdate(a,O,j)),typeof r.componentDidUpdate=="function"&&(t.flags|=4),typeof r.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),t.memoizedProps=a,t.memoizedState=O),r.props=a,r.state=O,r.context=j,a=R):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),a=!1)}return r=a,_i(e,t),a=(t.flags&128)!==0,r||a?(r=t.stateNode,l=a&&typeof l.getDerivedStateFromError!="function"?null:r.render(),t.flags|=1,e!==null&&a?(t.child=wa(t,e.child,null,s),t.child=wa(t,null,l,s)):ot(e,t,l,s),t.memoizedState=r.state,e=t.child):e=El(e,t,s),e}function Ud(e,t,l,a){return ya(),t.flags|=256,ot(e,t,l,a),t.child}var Rr={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Ur(e){return{baseLanes:e,cachePool:Ef()}}function Hr(e,t,l){return e=e!==null?e.childLanes&~l:0,t&&(e|=Rt),e}function Hd(e,t,l){var a=t.pendingProps,s=!1,r=(t.flags&128)!==0,m;if((m=r)||(m=e!==null&&e.memoizedState===null?!1:(Ke.current&2)!==0),m&&(s=!0,t.flags&=-129),m=(t.flags&32)!==0,t.flags&=-33,e===null){if(ve){if(s?Vl(t):Zl(),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data!=="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return yo(e)?t.lanes=32:t.lanes=536870912,null}var b=a.children;return a=a.fallback,s?(Zl(),s=t.mode,b=Ci({mode:"hidden",children:b},s),a=va(a,s,l,null),b.return=t,a.return=t,b.sibling=a,t.child=b,a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(null,a)):(Vl(t),Lr(t,b))}var j=e.memoizedState;if(j!==null&&(b=j.dehydrated,b!==null)){if(r)t.flags&256?(Vl(t),t.flags&=-257,t=Br(e,t,l)):t.memoizedState!==null?(Zl(),t.child=e.child,t.flags|=128,t=null):(Zl(),b=a.fallback,s=t.mode,a=Ci({mode:"visible",children:a.children},s),b=va(b,s,l,null),b.flags|=2,a.return=t,b.return=t,a.sibling=b,t.child=a,wa(t,e.child,null,l),a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,t=ss(null,a));else if(Vl(t),yo(b)){if(m=b.nextSibling&&b.nextSibling.dataset,m)var C=m.dgst;m=C,a=Error(u(419)),a.stack="",a.digest=m,Zn({value:a,source:null,stack:null}),t=Br(e,t,l)}else if(Pe||en(e,t,l,!1),m=(l&e.childLanes)!==0,Pe||m){if(m=Ue,m!==null&&(a=Nu(m,l),a!==0&&a!==j.retryLane))throw j.retryLane=a,ba(e,a),Tt(m,e,a),Or;vo(b)||Bi(),t=Br(e,t,l)}else vo(b)?(t.flags|=192,t.child=e.child,t=null):(e=j.treeContext,Le=Vt(b.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Lr(t,a.children),t.flags|=4096);return t}return s?(Zl(),b=a.fallback,s=t.mode,j=e.child,C=j.sibling,a=vl(j,{mode:"hidden",children:a.children}),a.subtreeFlags=j.subtreeFlags&65011712,C!==null?b=vl(C,b):(b=va(b,s,l,null),b.flags|=2),b.return=t,a.return=t,a.sibling=b,t.child=a,ss(null,a),a=t.child,b=e.child.memoizedState,b===null?b=Ur(l):(s=b.cachePool,s!==null?(j=Fe._currentValue,s=s.parent!==j?{parent:j,pool:j}:s):s=Ef(),b={baseLanes:b.baseLanes|l,cachePool:s}),a.memoizedState=b,a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(e.child,a)):(Vl(t),l=e.child,e=l.sibling,l=vl(l,{mode:"visible",children:a.children}),l.return=t,l.sibling=null,e!==null&&(m=t.deletions,m===null?(t.deletions=[e],t.flags|=16):m.push(e)),t.child=l,t.memoizedState=null,l)}function Lr(e,t){return t=Ci({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Ci(e,t){return e=Mt(22,e,null,t),e.lanes=0,e}function Br(e,t,l){return wa(t,e.child,null,l),e=Lr(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Ld(e,t,l){e.lanes|=t;var a=e.alternate;a!==null&&(a.lanes|=t),er(e.return,t,l)}function qr(e,t,l,a,s,r){var m=e.memoizedState;m===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:a,tail:l,tailMode:s,treeForkCount:r}:(m.isBackwards=t,m.rendering=null,m.renderingStartTime=0,m.last=a,m.tail=l,m.tailMode=s,m.treeForkCount=r)}function Bd(e,t,l){var a=t.pendingProps,s=a.revealOrder,r=a.tail;a=a.children;var m=Ke.current,b=(m&2)!==0;if(b?(m=m&1|2,t.flags|=128):m&=1,V(Ke,m),ot(e,t,a,l),a=ve?Vn:0,!b&&e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Ld(e,l,t);else if(e.tag===19)Ld(e,l,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}switch(s){case"forwards":for(l=t.child,s=null;l!==null;)e=l.alternate,e!==null&&bi(e)===null&&(s=l),l=l.sibling;l=s,l===null?(s=t.child,t.child=null):(s=l.sibling,l.sibling=null),qr(t,!1,s,l,r,a);break;case"backwards":case"unstable_legacy-backwards":for(l=null,s=t.child,t.child=null;s!==null;){if(e=s.alternate,e!==null&&bi(e)===null){t.child=s;break}e=s.sibling,s.sibling=l,l=s,s=e}qr(t,!0,l,null,r,a);break;case"together":qr(t,!1,null,null,void 0,a);break;default:t.memoizedState=null}return t.child}function El(e,t,l){if(e!==null&&(t.dependencies=e.dependencies),Wl|=t.lanes,(l&t.childLanes)===0)if(e!==null){if(en(e,t,l,!1),(l&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(u(153));if(t.child!==null){for(e=t.child,l=vl(e,e.pendingProps),t.child=l,l.return=t;e.sibling!==null;)e=e.sibling,l=l.sibling=vl(e,e.pendingProps),l.return=t;l.sibling=null}return t.child}function Yr(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&ui(e)))}function Ix(e,t,l){switch(t.tag){case 3:ze(t,t.stateNode.containerInfo),Gl(t,Fe,e.memoizedState.cache),ya();break;case 27:case 5:at(t);break;case 4:ze(t,t.stateNode.containerInfo);break;case 10:Gl(t,t.type,t.memoizedProps.value);break;case 31:if(t.memoizedState!==null)return t.flags|=128,dr(t),null;break;case 13:var a=t.memoizedState;if(a!==null)return a.dehydrated!==null?(Vl(t),t.flags|=128,null):(l&t.child.childLanes)!==0?Hd(e,t,l):(Vl(t),e=El(e,t,l),e!==null?e.sibling:null);Vl(t);break;case 19:var s=(e.flags&128)!==0;if(a=(l&t.childLanes)!==0,a||(en(e,t,l,!1),a=(l&t.childLanes)!==0),s){if(a)return Bd(e,t,l);t.flags|=128}if(s=t.memoizedState,s!==null&&(s.rendering=null,s.tail=null,s.lastEffect=null),V(Ke,Ke.current),a)break;return null;case 22:return t.lanes=0,Md(e,t,l,t.pendingProps);case 24:Gl(t,Fe,e.memoizedState.cache)}return El(e,t,l)}function qd(e,t,l){if(e!==null)if(e.memoizedProps!==t.pendingProps)Pe=!0;else{if(!Yr(e,l)&&(t.flags&128)===0)return Pe=!1,Ix(e,t,l);Pe=(e.flags&131072)!==0}else Pe=!1,ve&&(t.flags&1048576)!==0&&bf(t,Vn,t.index);switch(t.lanes=0,t.tag){case 16:e:{var a=t.pendingProps;if(e=za(t.elementType),t.type=e,typeof e=="function")Vc(e)?(a=Aa(e,a),t.tag=1,t=Rd(null,t,e,a,l)):(t.tag=0,t=Dr(null,t,e,a,l));else{if(e!=null){var s=e.$$typeof;if(s===P){t.tag=11,t=Ad(null,t,e,a,l);break e}else if(s===G){t.tag=14,t=_d(null,t,e,a,l);break e}}throw t=Qe(e)||e,Error(u(306,t,""))}}return t;case 0:return Dr(e,t,t.type,t.pendingProps,l);case 1:return a=t.type,s=Aa(a,t.pendingProps),Rd(e,t,a,s,l);case 3:e:{if(ze(t,t.stateNode.containerInfo),e===null)throw Error(u(387));a=t.pendingProps;var r=t.memoizedState;s=r.element,cr(e,t),es(t,a,null,l);var m=t.memoizedState;if(a=m.cache,Gl(t,Fe,a),a!==r.cache&&tr(t,[Fe],l,!0),Pn(),a=m.element,r.isDehydrated)if(r={element:a,isDehydrated:!1,cache:m.cache},t.updateQueue.baseState=r,t.memoizedState=r,t.flags&256){t=Ud(e,t,a,l);break e}else if(a!==s){s=Yt(Error(u(424)),t),Zn(s),t=Ud(e,t,a,l);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Le=Vt(e.firstChild),ct=t,ve=!0,ql=null,Qt=!0,l=Mf(t,null,a,l),t.child=l;l;)l.flags=l.flags&-3|4096,l=l.sibling}else{if(ya(),a===s){t=El(e,t,l);break e}ot(e,t,a,l)}t=t.child}return t;case 26:return _i(e,t),e===null?(l=Fm(t.type,null,t.pendingProps,null))?t.memoizedState=l:ve||(l=t.type,e=t.pendingProps,a=Vi(fe.current).createElement(l),a[it]=t,a[jt]=e,ut(a,l,e),nt(a),t.stateNode=a):t.memoizedState=Fm(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return at(t),e===null&&ve&&(a=t.stateNode=Km(t.type,t.pendingProps,fe.current),ct=t,Qt=!0,s=Le,ta(t.type)?(jo=s,Le=Vt(a.firstChild)):Le=s),ot(e,t,t.pendingProps.children,l),_i(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&ve&&((s=a=Le)&&(a=Ag(a,t.type,t.pendingProps,Qt),a!==null?(t.stateNode=a,ct=t,Le=Vt(a.firstChild),Qt=!1,s=!0):s=!1),s||Yl(t)),at(t),s=t.type,r=t.pendingProps,m=e!==null?e.memoizedProps:null,a=r.children,xo(s,r)?a=null:m!==null&&xo(s,m)&&(t.flags|=32),t.memoizedState!==null&&(s=hr(e,t,$x,null,null,l),ys._currentValue=s),_i(e,t),ot(e,t,a,l),t.child;case 6:return e===null&&ve&&((e=l=Le)&&(l=_g(l,t.pendingProps,Qt),l!==null?(t.stateNode=l,ct=t,Le=null,e=!0):e=!1),e||Yl(t)),null;case 13:return Hd(e,t,l);case 4:return ze(t,t.stateNode.containerInfo),a=t.pendingProps,e===null?t.child=wa(t,null,a,l):ot(e,t,a,l),t.child;case 11:return Ad(e,t,t.type,t.pendingProps,l);case 7:return ot(e,t,t.pendingProps,l),t.child;case 8:return ot(e,t,t.pendingProps.children,l),t.child;case 12:return ot(e,t,t.pendingProps.children,l),t.child;case 10:return a=t.pendingProps,Gl(t,t.type,a.value),ot(e,t,a.children,l),t.child;case 9:return s=t.type._context,a=t.pendingProps.children,Na(t),s=rt(s),a=a(s),t.flags|=1,ot(e,t,a,l),t.child;case 14:return _d(e,t,t.type,t.pendingProps,l);case 15:return Cd(e,t,t.type,t.pendingProps,l);case 19:return Bd(e,t,l);case 31:return Fx(e,t,l);case 22:return Md(e,t,l,t.pendingProps);case 24:return Na(t),a=rt(Fe),e===null?(s=nr(),s===null&&(s=Ue,r=lr(),s.pooledCache=r,r.refCount++,r!==null&&(s.pooledCacheLanes|=l),s=r),t.memoizedState={parent:a,cache:s},ir(t),Gl(t,Fe,s)):((e.lanes&l)!==0&&(cr(e,t),es(t,null,null,l),Pn()),s=e.memoizedState,r=t.memoizedState,s.parent!==a?(s={parent:a,cache:a},t.memoizedState=s,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=s),Gl(t,Fe,a)):(a=r.cache,Gl(t,Fe,a),a!==s.cache&&tr(t,[Fe],l,!0))),ot(e,t,t.pendingProps.children,l),t.child;case 29:throw t.pendingProps}throw Error(u(156,t.tag))}function wl(e){e.flags|=4}function Gr(e,t,l,a,s){if((t=(e.mode&32)!==0)&&(t=!1),t){if(e.flags|=16777216,(s&335544128)===s)if(e.stateNode.complete)e.flags|=8192;else if(mm())e.flags|=8192;else throw Ea=hi,sr}else e.flags&=-16777217}function Yd(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!lh(t))if(mm())e.flags|=8192;else throw Ea=hi,sr}function Mi(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?Zs():536870912,e.lanes|=t,mn|=t)}function is(e,t){if(!ve)switch(e.tailMode){case"hidden":t=e.tail;for(var l=null;t!==null;)t.alternate!==null&&(l=t),t=t.sibling;l===null?e.tail=null:l.sibling=null;break;case"collapsed":l=e.tail;for(var a=null;l!==null;)l.alternate!==null&&(a=l),l=l.sibling;a===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:a.sibling=null}}function Be(e){var t=e.alternate!==null&&e.alternate.child===e.child,l=0,a=0;if(t)for(var s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags&65011712,a|=s.flags&65011712,s.return=e,s=s.sibling;else for(s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags,a|=s.flags,s.return=e,s=s.sibling;return e.subtreeFlags|=a,e.childLanes=l,t}function Px(e,t,l){var a=t.pendingProps;switch(Wc(t),t.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Be(t),null;case 1:return Be(t),null;case 3:return l=t.stateNode,a=null,e!==null&&(a=e.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Nl(Fe),Ee(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(e===null||e.child===null)&&(Pa(t)?wl(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,Ic())),Be(t),null;case 26:var s=t.type,r=t.memoizedState;return e===null?(wl(t),r!==null?(Be(t),Yd(t,r)):(Be(t),Gr(t,s,null,a,l))):r?r!==e.memoizedState?(wl(t),Be(t),Yd(t,r)):(Be(t),t.flags&=-16777217):(e=e.memoizedProps,e!==a&&wl(t),Be(t),Gr(t,s,e,a,l)),null;case 27:if(Me(t),l=fe.current,s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}e=J.current,Pa(t)?yf(t):(e=Km(s,a,l),t.stateNode=e,wl(t))}return Be(t),null;case 5:if(Me(t),s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}if(r=J.current,Pa(t))yf(t);else{var m=Vi(fe.current);switch(r){case 1:r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case 2:r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;default:switch(s){case"svg":r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case"math":r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;case"script":r=m.createElement("div"),r.innerHTML="<script><\/script>",r=r.removeChild(r.firstChild);break;case"select":r=typeof a.is=="string"?m.createElement("select",{is:a.is}):m.createElement("select"),a.multiple?r.multiple=!0:a.size&&(r.size=a.size);break;default:r=typeof a.is=="string"?m.createElement(s,{is:a.is}):m.createElement(s)}}r[it]=t,r[jt]=a;e:for(m=t.child;m!==null;){if(m.tag===5||m.tag===6)r.appendChild(m.stateNode);else if(m.tag!==4&&m.tag!==27&&m.child!==null){m.child.return=m,m=m.child;continue}if(m===t)break e;for(;m.sibling===null;){if(m.return===null||m.return===t)break e;m=m.return}m.sibling.return=m.return,m=m.sibling}t.stateNode=r;e:switch(ut(r,s,a),s){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break e;case"img":a=!0;break e;default:a=!1}a&&wl(t)}}return Be(t),Gr(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(u(166));if(e=fe.current,Pa(t)){if(e=t.stateNode,l=t.memoizedProps,a=null,s=ct,s!==null)switch(s.tag){case 27:case 5:a=s.memoizedProps}e[it]=t,e=!!(e.nodeValue===l||a!==null&&a.suppressHydrationWarning===!0||Hm(e.nodeValue,l)),e||Yl(t,!0)}else e=Vi(e).createTextNode(a),e[it]=t,t.stateNode=e}return Be(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(a=Pa(t),l!==null){if(e===null){if(!a)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),e=!1}else l=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(Ot(t),t):(Ot(t),null);if((t.flags&128)!==0)throw Error(u(558))}return Be(t),null;case 13:if(a=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(s=Pa(t),a!==null&&a.dehydrated!==null){if(e===null){if(!s)throw Error(u(318));if(s=t.memoizedState,s=s!==null?s.dehydrated:null,!s)throw Error(u(317));s[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),s=!1}else s=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=s),s=!0;if(!s)return t.flags&256?(Ot(t),t):(Ot(t),null)}return Ot(t),(t.flags&128)!==0?(t.lanes=l,t):(l=a!==null,e=e!==null&&e.memoizedState!==null,l&&(a=t.child,s=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(s=a.alternate.memoizedState.cachePool.pool),r=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(r=a.memoizedState.cachePool.pool),r!==s&&(a.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),Mi(t,t.updateQueue),Be(t),null);case 4:return Ee(),e===null&&uo(t.stateNode.containerInfo),Be(t),null;case 10:return Nl(t.type),Be(t),null;case 19:if(L(Ke),a=t.memoizedState,a===null)return Be(t),null;if(s=(t.flags&128)!==0,r=a.rendering,r===null)if(s)is(a,!1);else{if(Ve!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(r=bi(e),r!==null){for(t.flags|=128,is(a,!1),e=r.updateQueue,t.updateQueue=e,Mi(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)pf(l,e),l=l.sibling;return V(Ke,Ke.current&1|2),ve&&yl(t,a.treeForkCount),t.child}e=e.sibling}a.tail!==null&&dt()>Ui&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304)}else{if(!s)if(e=bi(r),e!==null){if(t.flags|=128,s=!0,e=e.updateQueue,t.updateQueue=e,Mi(t,e),is(a,!0),a.tail===null&&a.tailMode==="hidden"&&!r.alternate&&!ve)return Be(t),null}else 2*dt()-a.renderingStartTime>Ui&&l!==536870912&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304);a.isBackwards?(r.sibling=t.child,t.child=r):(e=a.last,e!==null?e.sibling=r:t.child=r,a.last=r)}return a.tail!==null?(e=a.tail,a.rendering=e,a.tail=e.sibling,a.renderingStartTime=dt(),e.sibling=null,l=Ke.current,V(Ke,s?l&1|2:l&1),ve&&yl(t,a.treeForkCount),e):(Be(t),null);case 22:case 23:return Ot(t),fr(),a=t.memoizedState!==null,e!==null?e.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(l&536870912)!==0&&(t.flags&128)===0&&(Be(t),t.subtreeFlags&6&&(t.flags|=8192)):Be(t),l=t.updateQueue,l!==null&&Mi(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==l&&(t.flags|=2048),e!==null&&L(Sa),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),Nl(Fe),Be(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function eg(e,t){switch(Wc(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Nl(Fe),Ee(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Me(t),null;case 31:if(t.memoizedState!==null){if(Ot(t),t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ot(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return L(Ke),null;case 4:return Ee(),null;case 10:return Nl(t.type),null;case 22:case 23:return Ot(t),fr(),e!==null&&L(Sa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Nl(Fe),null;case 25:return null;default:return null}}function Gd(e,t){switch(Wc(t),t.tag){case 3:Nl(Fe),Ee();break;case 26:case 27:case 5:Me(t);break;case 4:Ee();break;case 31:t.memoizedState!==null&&Ot(t);break;case 13:Ot(t);break;case 19:L(Ke);break;case 10:Nl(t.type);break;case 22:case 23:Ot(t),fr(),e!==null&&L(Sa);break;case 24:Nl(Fe)}}function cs(e,t){try{var l=t.updateQueue,a=l!==null?l.lastEffect:null;if(a!==null){var s=a.next;l=s;do{if((l.tag&e)===e){a=void 0;var r=l.create,m=l.inst;a=r(),m.destroy=a}l=l.next}while(l!==s)}}catch(b){_e(t,t.return,b)}}function Kl(e,t,l){try{var a=t.updateQueue,s=a!==null?a.lastEffect:null;if(s!==null){var r=s.next;a=r;do{if((a.tag&e)===e){var m=a.inst,b=m.destroy;if(b!==void 0){m.destroy=void 0,s=t;var j=l,C=b;try{C()}catch(R){_e(s,j,R)}}}a=a.next}while(a!==r)}}catch(R){_e(t,t.return,R)}}function $d(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{Of(t,l)}catch(a){_e(e,e.return,a)}}}function Qd(e,t,l){l.props=Aa(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(a){_e(e,t,a)}}function rs(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var a=e.stateNode;break;case 30:a=e.stateNode;break;default:a=e.stateNode}typeof l=="function"?e.refCleanup=l(a):l.current=a}}catch(s){_e(e,t,s)}}function rl(e,t){var l=e.ref,a=e.refCleanup;if(l!==null)if(typeof a=="function")try{a()}catch(s){_e(e,t,s)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(s){_e(e,t,s)}else l.current=null}function Xd(e){var t=e.type,l=e.memoizedProps,a=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&a.focus();break e;case"img":l.src?a.src=l.src:l.srcSet&&(a.srcset=l.srcSet)}}catch(s){_e(e,e.return,s)}}function $r(e,t,l){try{var a=e.stateNode;Ng(a,e.type,l,t),a[jt]=t}catch(s){_e(e,e.return,s)}}function Vd(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&ta(e.type)||e.tag===4}function Qr(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&ta(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Xr(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=gl));else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(Xr(e,t,l),e=e.sibling;e!==null;)Xr(e,t,l),e=e.sibling}function ki(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(ki(e,t,l),e=e.sibling;e!==null;)ki(e,t,l),e=e.sibling}function Zd(e){var t=e.stateNode,l=e.memoizedProps;try{for(var a=e.type,s=t.attributes;s.length;)t.removeAttributeNode(s[0]);ut(t,a,l),t[it]=e,t[jt]=l}catch(r){_e(e,e.return,r)}}var Tl=!1,et=!1,Vr=!1,Kd=typeof WeakSet=="function"?WeakSet:Set,st=null;function tg(e,t){if(e=e.containerInfo,ho=Pi,e=sf(e),Bc(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var a=l.getSelection&&l.getSelection();if(a&&a.rangeCount!==0){l=a.anchorNode;var s=a.anchorOffset,r=a.focusNode;a=a.focusOffset;try{l.nodeType,r.nodeType}catch{l=null;break e}var m=0,b=-1,j=-1,C=0,R=0,B=e,M=null;t:for(;;){for(var O;B!==l||s!==0&&B.nodeType!==3||(b=m+s),B!==r||a!==0&&B.nodeType!==3||(j=m+a),B.nodeType===3&&(m+=B.nodeValue.length),(O=B.firstChild)!==null;)M=B,B=O;for(;;){if(B===e)break t;if(M===l&&++C===s&&(b=m),M===r&&++R===a&&(j=m),(O=B.nextSibling)!==null)break;B=M,M=B.parentNode}B=O}l=b===-1||j===-1?null:{start:b,end:j}}else l=null}l=l||{start:0,end:0}}else l=null;for(po={focusedElem:e,selectionRange:l},Pi=!1,st=t;st!==null;)if(t=st,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,st=e;else for(;st!==null;){switch(t=st,r=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l<e.length;l++)s=e[l],s.ref.impl=s.nextImpl;break;case 11:case 15:break;case 1:if((e&1024)!==0&&r!==null){e=void 0,l=t,s=r.memoizedProps,r=r.memoizedState,a=l.stateNode;try{var W=Aa(l.type,s);e=a.getSnapshotBeforeUpdate(W,r),a.__reactInternalSnapshotBeforeUpdate=e}catch(ie){_e(l,l.return,ie)}}break;case 3:if((e&1024)!==0){if(e=t.stateNode.containerInfo,l=e.nodeType,l===9)bo(e);else if(l===1)switch(e.nodeName){case"HEAD":case"HTML":case"BODY":bo(e);break;default:e.textContent=""}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;default:if((e&1024)!==0)throw Error(u(163))}if(e=t.sibling,e!==null){e.return=t.return,st=e;break}st=t.return}}function Jd(e,t,l){var a=l.flags;switch(l.tag){case 0:case 11:case 15:_l(e,l),a&4&&cs(5,l);break;case 1:if(_l(e,l),a&4)if(e=l.stateNode,t===null)try{e.componentDidMount()}catch(m){_e(l,l.return,m)}else{var s=Aa(l.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(s,t,e.__reactInternalSnapshotBeforeUpdate)}catch(m){_e(l,l.return,m)}}a&64&&$d(l),a&512&&rs(l,l.return);break;case 3:if(_l(e,l),a&64&&(e=l.updateQueue,e!==null)){if(t=null,l.child!==null)switch(l.child.tag){case 27:case 5:t=l.child.stateNode;break;case 1:t=l.child.stateNode}try{Of(e,t)}catch(m){_e(l,l.return,m)}}break;case 27:t===null&&a&4&&Zd(l);case 26:case 5:_l(e,l),t===null&&a&4&&Xd(l),a&512&&rs(l,l.return);break;case 12:_l(e,l);break;case 31:_l(e,l),a&4&&Id(e,l);break;case 13:_l(e,l),a&4&&Pd(e,l),a&64&&(e=l.memoizedState,e!==null&&(e=e.dehydrated,e!==null&&(l=ug.bind(null,l),Cg(e,l))));break;case 22:if(a=l.memoizedState!==null||Tl,!a){t=t!==null&&t.memoizedState!==null||et,s=Tl;var r=et;Tl=a,(et=t)&&!r?Cl(e,l,(l.subtreeFlags&8772)!==0):_l(e,l),Tl=s,et=r}break;case 30:break;default:_l(e,l)}}function Wd(e){var t=e.alternate;t!==null&&(e.alternate=null,Wd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&Nc(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var qe=null,St=!1;function Al(e,t,l){for(l=l.child;l!==null;)Fd(e,t,l),l=l.sibling}function Fd(e,t,l){if(pt&&typeof pt.onCommitFiberUnmount=="function")try{pt.onCommitFiberUnmount(al,l)}catch{}switch(l.tag){case 26:et||rl(l,t),Al(e,t,l),l.memoizedState?l.memoizedState.count--:l.stateNode&&(l=l.stateNode,l.parentNode.removeChild(l));break;case 27:et||rl(l,t);var a=qe,s=St;ta(l.type)&&(qe=l.stateNode,St=!1),Al(e,t,l),gs(l.stateNode),qe=a,St=s;break;case 5:et||rl(l,t);case 6:if(a=qe,s=St,qe=null,Al(e,t,l),qe=a,St=s,qe!==null)if(St)try{(qe.nodeType===9?qe.body:qe.nodeName==="HTML"?qe.ownerDocument.body:qe).removeChild(l.stateNode)}catch(r){_e(l,t,r)}else try{qe.removeChild(l.stateNode)}catch(r){_e(l,t,r)}break;case 18:qe!==null&&(St?(e=qe,$m(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.stateNode),jn(e)):$m(qe,l.stateNode));break;case 4:a=qe,s=St,qe=l.stateNode.containerInfo,St=!0,Al(e,t,l),qe=a,St=s;break;case 0:case 11:case 14:case 15:Kl(2,l,t),et||Kl(4,l,t),Al(e,t,l);break;case 1:et||(rl(l,t),a=l.stateNode,typeof a.componentWillUnmount=="function"&&Qd(l,t,a)),Al(e,t,l);break;case 21:Al(e,t,l);break;case 22:et=(a=et)||l.memoizedState!==null,Al(e,t,l),et=a;break;default:Al(e,t,l)}}function Id(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null))){e=e.dehydrated;try{jn(e)}catch(l){_e(t,t.return,l)}}}function Pd(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null&&(e=e.dehydrated,e!==null))))try{jn(e)}catch(l){_e(t,t.return,l)}}function lg(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return t===null&&(t=e.stateNode=new Kd),t;case 22:return e=e.stateNode,t=e._retryCache,t===null&&(t=e._retryCache=new Kd),t;default:throw Error(u(435,e.tag))}}function Oi(e,t){var l=lg(e);t.forEach(function(a){if(!l.has(a)){l.add(a);var s=fg.bind(null,e,a);a.then(s,s)}})}function zt(e,t){var l=t.deletions;if(l!==null)for(var a=0;a<l.length;a++){var s=l[a],r=e,m=t,b=m;e:for(;b!==null;){switch(b.tag){case 27:if(ta(b.type)){qe=b.stateNode,St=!1;break e}break;case 5:qe=b.stateNode,St=!1;break e;case 3:case 4:qe=b.stateNode.containerInfo,St=!0;break e}b=b.return}if(qe===null)throw Error(u(160));Fd(r,m,s),qe=null,St=!1,r=s.alternate,r!==null&&(r.return=null),s.return=null}if(t.subtreeFlags&13886)for(t=t.child;t!==null;)em(t,e),t=t.sibling}var It=null;function em(e,t){var l=e.alternate,a=e.flags;switch(e.tag){case 0:case 11:case 14:case 15:zt(t,e),Et(e),a&4&&(Kl(3,e,e.return),cs(3,e),Kl(5,e,e.return));break;case 1:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&64&&Tl&&(e=e.updateQueue,e!==null&&(a=e.callbacks,a!==null&&(l=e.shared.hiddenCallbacks,e.shared.hiddenCallbacks=l===null?a:l.concat(a))));break;case 26:var s=It;if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&4){var r=l!==null?l.memoizedState:null;if(a=e.memoizedState,l===null)if(a===null)if(e.stateNode===null){e:{a=e.type,l=e.memoizedProps,s=s.ownerDocument||s;t:switch(a){case"title":r=s.getElementsByTagName("title")[0],(!r||r[Rn]||r[it]||r.namespaceURI==="http://www.w3.org/2000/svg"||r.hasAttribute("itemprop"))&&(r=s.createElement(a),s.head.insertBefore(r,s.querySelector("head > title"))),ut(r,a,l),r[it]=e,nt(r),a=r;break e;case"link":var m=eh("link","href",s).get(a+(l.href||""));if(m){for(var b=0;b<m.length;b++)if(r=m[b],r.getAttribute("href")===(l.href==null||l.href===""?null:l.href)&&r.getAttribute("rel")===(l.rel==null?null:l.rel)&&r.getAttribute("title")===(l.title==null?null:l.title)&&r.getAttribute("crossorigin")===(l.crossOrigin==null?null:l.crossOrigin)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;case"meta":if(m=eh("meta","content",s).get(a+(l.content||""))){for(b=0;b<m.length;b++)if(r=m[b],r.getAttribute("content")===(l.content==null?null:""+l.content)&&r.getAttribute("name")===(l.name==null?null:l.name)&&r.getAttribute("property")===(l.property==null?null:l.property)&&r.getAttribute("http-equiv")===(l.httpEquiv==null?null:l.httpEquiv)&&r.getAttribute("charset")===(l.charSet==null?null:l.charSet)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;default:throw Error(u(468,a))}r[it]=e,nt(r),a=r}e.stateNode=a}else th(s,e.type,e.stateNode);else e.stateNode=Pm(s,a,e.memoizedProps);else r!==a?(r===null?l.stateNode!==null&&(l=l.stateNode,l.parentNode.removeChild(l)):r.count--,a===null?th(s,e.type,e.stateNode):Pm(s,a,e.memoizedProps)):a===null&&e.stateNode!==null&&$r(e,e.memoizedProps,l.memoizedProps)}break;case 27:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),l!==null&&a&4&&$r(e,e.memoizedProps,l.memoizedProps);break;case 5:if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),e.flags&32){s=e.stateNode;try{$a(s,"")}catch(W){_e(e,e.return,W)}}a&4&&e.stateNode!=null&&(s=e.memoizedProps,$r(e,s,l!==null?l.memoizedProps:s)),a&1024&&(Vr=!0);break;case 6:if(zt(t,e),Et(e),a&4){if(e.stateNode===null)throw Error(u(162));a=e.memoizedProps,l=e.stateNode;try{l.nodeValue=a}catch(W){_e(e,e.return,W)}}break;case 3:if(Ji=null,s=It,It=Zi(t.containerInfo),zt(t,e),It=s,Et(e),a&4&&l!==null&&l.memoizedState.isDehydrated)try{jn(t.containerInfo)}catch(W){_e(e,e.return,W)}Vr&&(Vr=!1,tm(e));break;case 4:a=It,It=Zi(e.stateNode.containerInfo),zt(t,e),Et(e),It=a;break;case 12:zt(t,e),Et(e);break;case 31:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 13:zt(t,e),Et(e),e.child.flags&8192&&e.memoizedState!==null!=(l!==null&&l.memoizedState!==null)&&(Ri=dt()),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 22:s=e.memoizedState!==null;var j=l!==null&&l.memoizedState!==null,C=Tl,R=et;if(Tl=C||s,et=R||j,zt(t,e),et=R,Tl=C,Et(e),a&8192)e:for(t=e.stateNode,t._visibility=s?t._visibility&-2:t._visibility|1,s&&(l===null||j||Tl||et||_a(e)),l=null,t=e;;){if(t.tag===5||t.tag===26){if(l===null){j=l=t;try{if(r=j.stateNode,s)m=r.style,typeof m.setProperty=="function"?m.setProperty("display","none","important"):m.display="none";else{b=j.stateNode;var B=j.memoizedProps.style,M=B!=null&&B.hasOwnProperty("display")?B.display:null;b.style.display=M==null||typeof M=="boolean"?"":(""+M).trim()}}catch(W){_e(j,j.return,W)}}}else if(t.tag===6){if(l===null){j=t;try{j.stateNode.nodeValue=s?"":j.memoizedProps}catch(W){_e(j,j.return,W)}}}else if(t.tag===18){if(l===null){j=t;try{var O=j.stateNode;s?Qm(O,!0):Qm(j.stateNode,!1)}catch(W){_e(j,j.return,W)}}}else if((t.tag!==22&&t.tag!==23||t.memoizedState===null||t===e)&&t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break e;for(;t.sibling===null;){if(t.return===null||t.return===e)break e;l===t&&(l=null),t=t.return}l===t&&(l=null),t.sibling.return=t.return,t=t.sibling}a&4&&(a=e.updateQueue,a!==null&&(l=a.retryQueue,l!==null&&(a.retryQueue=null,Oi(e,l))));break;case 19:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 30:break;case 21:break;default:zt(t,e),Et(e)}}function Et(e){var t=e.flags;if(t&2){try{for(var l,a=e.return;a!==null;){if(Vd(a)){l=a;break}a=a.return}if(l==null)throw Error(u(160));switch(l.tag){case 27:var s=l.stateNode,r=Qr(e);ki(e,r,s);break;case 5:var m=l.stateNode;l.flags&32&&($a(m,""),l.flags&=-33);var b=Qr(e);ki(e,b,m);break;case 3:case 4:var j=l.stateNode.containerInfo,C=Qr(e);Xr(e,C,j);break;default:throw Error(u(161))}}catch(R){_e(e,e.return,R)}e.flags&=-3}t&4096&&(e.flags&=-4097)}function tm(e){if(e.subtreeFlags&1024)for(e=e.child;e!==null;){var t=e;tm(t),t.tag===5&&t.flags&1024&&t.stateNode.reset(),e=e.sibling}}function _l(e,t){if(t.subtreeFlags&8772)for(t=t.child;t!==null;)Jd(e,t.alternate,t),t=t.sibling}function _a(e){for(e=e.child;e!==null;){var t=e;switch(t.tag){case 0:case 11:case 14:case 15:Kl(4,t,t.return),_a(t);break;case 1:rl(t,t.return);var l=t.stateNode;typeof l.componentWillUnmount=="function"&&Qd(t,t.return,l),_a(t);break;case 27:gs(t.stateNode);case 26:case 5:rl(t,t.return),_a(t);break;case 22:t.memoizedState===null&&_a(t);break;case 30:_a(t);break;default:_a(t)}e=e.sibling}}function Cl(e,t,l){for(l=l&&(t.subtreeFlags&8772)!==0,t=t.child;t!==null;){var a=t.alternate,s=e,r=t,m=r.flags;switch(r.tag){case 0:case 11:case 15:Cl(s,r,l),cs(4,r);break;case 1:if(Cl(s,r,l),a=r,s=a.stateNode,typeof s.componentDidMount=="function")try{s.componentDidMount()}catch(C){_e(a,a.return,C)}if(a=r,s=a.updateQueue,s!==null){var b=a.stateNode;try{var j=s.shared.hiddenCallbacks;if(j!==null)for(s.shared.hiddenCallbacks=null,s=0;s<j.length;s++)kf(j[s],b)}catch(C){_e(a,a.return,C)}}l&&m&64&&$d(r),rs(r,r.return);break;case 27:Zd(r);case 26:case 5:Cl(s,r,l),l&&a===null&&m&4&&Xd(r),rs(r,r.return);break;case 12:Cl(s,r,l);break;case 31:Cl(s,r,l),l&&m&4&&Id(s,r);break;case 13:Cl(s,r,l),l&&m&4&&Pd(s,r);break;case 22:r.memoizedState===null&&Cl(s,r,l),rs(r,r.return);break;case 30:break;default:Cl(s,r,l)}t=t.sibling}}function Zr(e,t){var l=null;e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==l&&(e!=null&&e.refCount++,l!=null&&Kn(l))}function Kr(e,t){e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e))}function Pt(e,t,l,a){if(t.subtreeFlags&10256)for(t=t.child;t!==null;)lm(e,t,l,a),t=t.sibling}function lm(e,t,l,a){var s=t.flags;switch(t.tag){case 0:case 11:case 15:Pt(e,t,l,a),s&2048&&cs(9,t);break;case 1:Pt(e,t,l,a);break;case 3:Pt(e,t,l,a),s&2048&&(e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e)));break;case 12:if(s&2048){Pt(e,t,l,a),e=t.stateNode;try{var r=t.memoizedProps,m=r.id,b=r.onPostCommit;typeof b=="function"&&b(m,t.alternate===null?"mount":"update",e.passiveEffectDuration,-0)}catch(j){_e(t,t.return,j)}}else Pt(e,t,l,a);break;case 31:Pt(e,t,l,a);break;case 13:Pt(e,t,l,a);break;case 23:break;case 22:r=t.stateNode,m=t.alternate,t.memoizedState!==null?r._visibility&2?Pt(e,t,l,a):os(e,t):r._visibility&2?Pt(e,t,l,a):(r._visibility|=2,un(e,t,l,a,(t.subtreeFlags&10256)!==0||!1)),s&2048&&Zr(m,t);break;case 24:Pt(e,t,l,a),s&2048&&Kr(t.alternate,t);break;default:Pt(e,t,l,a)}}function un(e,t,l,a,s){for(s=s&&((t.subtreeFlags&10256)!==0||!1),t=t.child;t!==null;){var r=e,m=t,b=l,j=a,C=m.flags;switch(m.tag){case 0:case 11:case 15:un(r,m,b,j,s),cs(8,m);break;case 23:break;case 22:var R=m.stateNode;m.memoizedState!==null?R._visibility&2?un(r,m,b,j,s):os(r,m):(R._visibility|=2,un(r,m,b,j,s)),s&&C&2048&&Zr(m.alternate,m);break;case 24:un(r,m,b,j,s),s&&C&2048&&Kr(m.alternate,m);break;default:un(r,m,b,j,s)}t=t.sibling}}function os(e,t){if(t.subtreeFlags&10256)for(t=t.child;t!==null;){var l=e,a=t,s=a.flags;switch(a.tag){case 22:os(l,a),s&2048&&Zr(a.alternate,a);break;case 24:os(l,a),s&2048&&Kr(a.alternate,a);break;default:os(l,a)}t=t.sibling}}var us=8192;function fn(e,t,l){if(e.subtreeFlags&us)for(e=e.child;e!==null;)am(e,t,l),e=e.sibling}function am(e,t,l){switch(e.tag){case 26:fn(e,t,l),e.flags&us&&e.memoizedState!==null&&Gg(l,It,e.memoizedState,e.memoizedProps);break;case 5:fn(e,t,l);break;case 3:case 4:var a=It;It=Zi(e.stateNode.containerInfo),fn(e,t,l),It=a;break;case 22:e.memoizedState===null&&(a=e.alternate,a!==null&&a.memoizedState!==null?(a=us,us=16777216,fn(e,t,l),us=a):fn(e,t,l));break;default:fn(e,t,l)}}function nm(e){var t=e.alternate;if(t!==null&&(e=t.child,e!==null)){t.child=null;do t=e.sibling,e.sibling=null,e=t;while(e!==null)}}function fs(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}if(e.subtreeFlags&10256)for(e=e.child;e!==null;)sm(e),e=e.sibling}function sm(e){switch(e.tag){case 0:case 11:case 15:fs(e),e.flags&2048&&Kl(9,e,e.return);break;case 3:fs(e);break;case 12:fs(e);break;case 22:var t=e.stateNode;e.memoizedState!==null&&t._visibility&2&&(e.return===null||e.return.tag!==13)?(t._visibility&=-3,Di(e)):fs(e);break;default:fs(e)}}function Di(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}for(e=e.child;e!==null;){switch(t=e,t.tag){case 0:case 11:case 15:Kl(8,t,t.return),Di(t);break;case 22:l=t.stateNode,l._visibility&2&&(l._visibility&=-3,Di(t));break;default:Di(t)}e=e.sibling}}function im(e,t){for(;st!==null;){var l=st;switch(l.tag){case 0:case 11:case 15:Kl(8,l,t);break;case 23:case 22:if(l.memoizedState!==null&&l.memoizedState.cachePool!==null){var a=l.memoizedState.cachePool.pool;a!=null&&a.refCount++}break;case 24:Kn(l.memoizedState.cache)}if(a=l.child,a!==null)a.return=l,st=a;else e:for(l=e;st!==null;){a=st;var s=a.sibling,r=a.return;if(Wd(a),a===l){st=null;break e}if(s!==null){s.return=r,st=s;break e}st=r}}}var ag={getCacheForType:function(e){var t=rt(Fe),l=t.data.get(e);return l===void 0&&(l=e(),t.data.set(e,l)),l},cacheSignal:function(){return rt(Fe).controller.signal}},ng=typeof WeakMap=="function"?WeakMap:Map,we=0,Ue=null,pe=null,ge=0,Ae=0,Dt=null,Jl=!1,dn=!1,Jr=!1,Ml=0,Ve=0,Wl=0,Ca=0,Wr=0,Rt=0,mn=0,ds=null,wt=null,Fr=!1,Ri=0,cm=0,Ui=1/0,Hi=null,Fl=null,lt=0,Il=null,hn=null,kl=0,Ir=0,Pr=null,rm=null,ms=0,eo=null;function Ut(){return(we&2)!==0&&ge!==0?ge&-ge:k.T!==null?io():Su()}function om(){if(Rt===0)if((ge&536870912)===0||ve){var e=da;da<<=1,(da&3932160)===0&&(da=262144),Rt=e}else Rt=536870912;return e=kt.current,e!==null&&(e.flags|=32),Rt}function Tt(e,t,l){(e===Ue&&(Ae===2||Ae===9)||e.cancelPendingCommit!==null)&&(pn(e,0),Pl(e,ge,Rt,!1)),Dn(e,l),((we&2)===0||e!==Ue)&&(e===Ue&&((we&2)===0&&(Ca|=l),Ve===4&&Pl(e,ge,Rt,!1)),ol(e))}function um(e,t,l){if((we&6)!==0)throw Error(u(327));var a=!l&&(t&127)===0&&(t&e.expiredLanes)===0||Ul(e,t),s=a?cg(e,t):lo(e,t,!0),r=a;do{if(s===0){dn&&!a&&Pl(e,t,0,!1);break}else{if(l=e.current.alternate,r&&!sg(l)){s=lo(e,t,!1),r=!1;continue}if(s===2){if(r=t,e.errorRecoveryDisabledLanes&r)var m=0;else m=e.pendingLanes&-536870913,m=m!==0?m:m&536870912?536870912:0;if(m!==0){t=m;e:{var b=e;s=ds;var j=b.current.memoizedState.isDehydrated;if(j&&(pn(b,m).flags|=256),m=lo(b,m,!1),m!==2){if(Jr&&!j){b.errorRecoveryDisabledLanes|=r,Ca|=r,s=4;break e}r=wt,wt=s,r!==null&&(wt===null?wt=r:wt.push.apply(wt,r))}s=m}if(r=!1,s!==2)continue}}if(s===1){pn(e,0),Pl(e,t,0,!0);break}e:{switch(a=e,r=s,r){case 0:case 1:throw Error(u(345));case 4:if((t&4194048)!==t)break;case 6:Pl(a,t,Rt,!Jl);break e;case 2:wt=null;break;case 3:case 5:break;default:throw Error(u(329))}if((t&62914560)===t&&(s=Ri+300-dt(),10<s)){if(Pl(a,t,Rt,!Jl),Ua(a,0,!0)!==0)break e;kl=t,a.timeoutHandle=Ym(fm.bind(null,a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,"Throttled",-0,0),s);break e}fm(a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,null,-0,0)}}break}while(!0);ol(e)}function fm(e,t,l,a,s,r,m,b,j,C,R,B,M,O){if(e.timeoutHandle=-1,B=t.subtreeFlags,B&8192||(B&16785408)===16785408){B={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:gl},am(t,r,B);var W=(r&62914560)===r?Ri-dt():(r&4194048)===r?cm-dt():0;if(W=$g(B,W),W!==null){kl=r,e.cancelPendingCommit=W(vm.bind(null,e,t,r,l,a,s,m,b,j,R,B,null,M,O)),Pl(e,r,m,!C);return}}vm(e,t,r,l,a,s,m,b,j)}function sg(e){for(var t=e;;){var l=t.tag;if((l===0||l===11||l===15)&&t.flags&16384&&(l=t.updateQueue,l!==null&&(l=l.stores,l!==null)))for(var a=0;a<l.length;a++){var s=l[a],r=s.getSnapshot;s=s.value;try{if(!Ct(r(),s))return!1}catch{return!1}}if(l=t.child,t.subtreeFlags&16384&&l!==null)l.return=t,t=l;else{if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return!0;t=t.return}t.sibling.return=t.return,t=t.sibling}}return!0}function Pl(e,t,l,a){t&=~Wr,t&=~Ca,e.suspendedLanes|=t,e.pingedLanes&=~t,a&&(e.warmLanes|=t),a=e.expirationTimes;for(var s=t;0<s;){var r=31-xt(s),m=1<<r;a[r]=-1,s&=~m}l!==0&&yu(e,l,t)}function Li(){return(we&6)===0?(hs(0),!1):!0}function to(){if(pe!==null){if(Ae===0)var e=pe.return;else e=pe,jl=ja=null,gr(e),nn=null,Wn=0,e=pe;for(;e!==null;)Gd(e.alternate,e),e=e.return;pe=null}}function pn(e,t){var l=e.timeoutHandle;l!==-1&&(e.timeoutHandle=-1,Eg(l)),l=e.cancelPendingCommit,l!==null&&(e.cancelPendingCommit=null,l()),kl=0,to(),Ue=e,pe=l=vl(e.current,null),ge=t,Ae=0,Dt=null,Jl=!1,dn=Ul(e,t),Jr=!1,mn=Rt=Wr=Ca=Wl=Ve=0,wt=ds=null,Fr=!1,(t&8)!==0&&(t|=t&32);var a=e.entangledLanes;if(a!==0)for(e=e.entanglements,a&=t;0<a;){var s=31-xt(a),r=1<<s;t|=e[s],a&=~r}return Ml=t,si(),l}function dm(e,t){de=null,k.H=ns,t===an||t===mi?(t=Af(),Ae=3):t===sr?(t=Af(),Ae=4):Ae=t===Or?8:t!==null&&typeof t=="object"&&typeof t.then=="function"?6:1,Dt=t,pe===null&&(Ve=1,Ti(e,Yt(t,e.current)))}function mm(){var e=kt.current;return e===null?!0:(ge&4194048)===ge?Xt===null:(ge&62914560)===ge||(ge&536870912)!==0?e===Xt:!1}function hm(){var e=k.H;return k.H=ns,e===null?ns:e}function pm(){var e=k.A;return k.A=ag,e}function Bi(){Ve=4,Jl||(ge&4194048)!==ge&&kt.current!==null||(dn=!0),(Wl&134217727)===0&&(Ca&134217727)===0||Ue===null||Pl(Ue,ge,Rt,!1)}function lo(e,t,l){var a=we;we|=2;var s=hm(),r=pm();(Ue!==e||ge!==t)&&(Hi=null,pn(e,t)),t=!1;var m=Ve;e:do try{if(Ae!==0&&pe!==null){var b=pe,j=Dt;switch(Ae){case 8:to(),m=6;break e;case 3:case 2:case 9:case 6:kt.current===null&&(t=!0);var C=Ae;if(Ae=0,Dt=null,xn(e,b,j,C),l&&dn){m=0;break e}break;default:C=Ae,Ae=0,Dt=null,xn(e,b,j,C)}}ig(),m=Ve;break}catch(R){dm(e,R)}while(!0);return t&&e.shellSuspendCounter++,jl=ja=null,we=a,k.H=s,k.A=r,pe===null&&(Ue=null,ge=0,si()),m}function ig(){for(;pe!==null;)xm(pe)}function cg(e,t){var l=we;we|=2;var a=hm(),s=pm();Ue!==e||ge!==t?(Hi=null,Ui=dt()+500,pn(e,t)):dn=Ul(e,t);e:do try{if(Ae!==0&&pe!==null){t=pe;var r=Dt;t:switch(Ae){case 1:Ae=0,Dt=null,xn(e,t,r,1);break;case 2:case 9:if(wf(r)){Ae=0,Dt=null,gm(t);break}t=function(){Ae!==2&&Ae!==9||Ue!==e||(Ae=7),ol(e)},r.then(t,t);break e;case 3:Ae=7;break e;case 4:Ae=5;break e;case 7:wf(r)?(Ae=0,Dt=null,gm(t)):(Ae=0,Dt=null,xn(e,t,r,7));break;case 5:var m=null;switch(pe.tag){case 26:m=pe.memoizedState;case 5:case 27:var b=pe;if(m?lh(m):b.stateNode.complete){Ae=0,Dt=null;var j=b.sibling;if(j!==null)pe=j;else{var C=b.return;C!==null?(pe=C,qi(C)):pe=null}break t}}Ae=0,Dt=null,xn(e,t,r,5);break;case 6:Ae=0,Dt=null,xn(e,t,r,6);break;case 8:to(),Ve=6;break e;default:throw Error(u(462))}}rg();break}catch(R){dm(e,R)}while(!0);return jl=ja=null,k.H=a,k.A=s,we=l,pe!==null?0:(Ue=null,ge=0,si(),Ve)}function rg(){for(;pe!==null&&!An();)xm(pe)}function xm(e){var t=qd(e.alternate,e,Ml);e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function gm(e){var t=e,l=t.alternate;switch(t.tag){case 15:case 0:t=Dd(l,t,t.pendingProps,t.type,void 0,ge);break;case 11:t=Dd(l,t,t.pendingProps,t.type.render,t.ref,ge);break;case 5:gr(t);default:Gd(l,t),t=pe=pf(t,Ml),t=qd(l,t,Ml)}e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function xn(e,t,l,a){jl=ja=null,gr(t),nn=null,Wn=0;var s=t.return;try{if(Wx(e,s,t,l,ge)){Ve=1,Ti(e,Yt(l,e.current)),pe=null;return}}catch(r){if(s!==null)throw pe=s,r;Ve=1,Ti(e,Yt(l,e.current)),pe=null;return}t.flags&32768?(ve||a===1?e=!0:dn||(ge&536870912)!==0?e=!1:(Jl=e=!0,(a===2||a===9||a===3||a===6)&&(a=kt.current,a!==null&&a.tag===13&&(a.flags|=16384))),bm(t,e)):qi(t)}function qi(e){var t=e;do{if((t.flags&32768)!==0){bm(t,Jl);return}e=t.return;var l=Px(t.alternate,t,Ml);if(l!==null){pe=l;return}if(t=t.sibling,t!==null){pe=t;return}pe=t=e}while(t!==null);Ve===0&&(Ve=5)}function bm(e,t){do{var l=eg(e.alternate,e);if(l!==null){l.flags&=32767,pe=l;return}if(l=e.return,l!==null&&(l.flags|=32768,l.subtreeFlags=0,l.deletions=null),!t&&(e=e.sibling,e!==null)){pe=e;return}pe=e=l}while(e!==null);Ve=6,pe=null}function vm(e,t,l,a,s,r,m,b,j){e.cancelPendingCommit=null;do Yi();while(lt!==0);if((we&6)!==0)throw Error(u(327));if(t!==null){if(t===e.current)throw Error(u(177));if(r=t.lanes|t.childLanes,r|=Qc,Yp(e,l,r,m,b,j),e===Ue&&(pe=Ue=null,ge=0),hn=t,Il=e,kl=l,Ir=r,Pr=s,rm=a,(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?(e.callbackNode=null,e.callbackPriority=0,dg(Da,function(){return zm(),null})):(e.callbackNode=null,e.callbackPriority=0),a=(t.flags&13878)!==0,(t.subtreeFlags&13878)!==0||a){a=k.T,k.T=null,s=Y.p,Y.p=2,m=we,we|=4;try{tg(e,t,l)}finally{we=m,Y.p=s,k.T=a}}lt=1,ym(),jm(),Nm()}}function ym(){if(lt===1){lt=0;var e=Il,t=hn,l=(t.flags&13878)!==0;if((t.subtreeFlags&13878)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{em(t,e);var r=po,m=sf(e.containerInfo),b=r.focusedElem,j=r.selectionRange;if(m!==b&&b&&b.ownerDocument&&nf(b.ownerDocument.documentElement,b)){if(j!==null&&Bc(b)){var C=j.start,R=j.end;if(R===void 0&&(R=C),"selectionStart"in b)b.selectionStart=C,b.selectionEnd=Math.min(R,b.value.length);else{var B=b.ownerDocument||document,M=B&&B.defaultView||window;if(M.getSelection){var O=M.getSelection(),W=b.textContent.length,ie=Math.min(j.start,W),Re=j.end===void 0?ie:Math.min(j.end,W);!O.extend&&ie>Re&&(m=Re,Re=ie,ie=m);var T=af(b,ie),E=af(b,Re);if(T&&E&&(O.rangeCount!==1||O.anchorNode!==T.node||O.anchorOffset!==T.offset||O.focusNode!==E.node||O.focusOffset!==E.offset)){var _=B.createRange();_.setStart(T.node,T.offset),O.removeAllRanges(),ie>Re?(O.addRange(_),O.extend(E.node,E.offset)):(_.setEnd(E.node,E.offset),O.addRange(_))}}}}for(B=[],O=b;O=O.parentNode;)O.nodeType===1&&B.push({element:O,left:O.scrollLeft,top:O.scrollTop});for(typeof b.focus=="function"&&b.focus(),b=0;b<B.length;b++){var H=B[b];H.element.scrollLeft=H.left,H.element.scrollTop=H.top}}Pi=!!ho,po=ho=null}finally{we=s,Y.p=a,k.T=l}}e.current=t,lt=2}}function jm(){if(lt===2){lt=0;var e=Il,t=hn,l=(t.flags&8772)!==0;if((t.subtreeFlags&8772)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{Jd(e,t.alternate,t)}finally{we=s,Y.p=a,k.T=l}}lt=3}}function Nm(){if(lt===4||lt===3){lt=0,_n();var e=Il,t=hn,l=kl,a=rm;(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?lt=5:(lt=0,hn=Il=null,Sm(e,e.pendingLanes));var s=e.pendingLanes;if(s===0&&(Fl=null),yc(l),t=t.stateNode,pt&&typeof pt.onCommitFiberRoot=="function")try{pt.onCommitFiberRoot(al,t,void 0,(t.current.flags&128)===128)}catch{}if(a!==null){t=k.T,s=Y.p,Y.p=2,k.T=null;try{for(var r=e.onRecoverableError,m=0;m<a.length;m++){var b=a[m];r(b.value,{componentStack:b.stack})}}finally{k.T=t,Y.p=s}}(kl&3)!==0&&Yi(),ol(e),s=e.pendingLanes,(l&261930)!==0&&(s&42)!==0?e===eo?ms++:(ms=0,eo=e):ms=0,hs(0)}}function Sm(e,t){(e.pooledCacheLanes&=t)===0&&(t=e.pooledCache,t!=null&&(e.pooledCache=null,Kn(t)))}function Yi(){return ym(),jm(),Nm(),zm()}function zm(){if(lt!==5)return!1;var e=Il,t=Ir;Ir=0;var l=yc(kl),a=k.T,s=Y.p;try{Y.p=32>l?32:l,k.T=null,l=Pr,Pr=null;var r=Il,m=kl;if(lt=0,hn=Il=null,kl=0,(we&6)!==0)throw Error(u(331));var b=we;if(we|=4,sm(r.current),lm(r,r.current,m,l),we=b,hs(0,!1),pt&&typeof pt.onPostCommitFiberRoot=="function")try{pt.onPostCommitFiberRoot(al,r)}catch{}return!0}finally{Y.p=s,k.T=a,Sm(e,t)}}function Em(e,t,l){t=Yt(l,t),t=kr(e.stateNode,t,2),e=Xl(e,t,2),e!==null&&(Dn(e,2),ol(e))}function _e(e,t,l){if(e.tag===3)Em(e,e,l);else for(;t!==null;){if(t.tag===3){Em(t,e,l);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Fl===null||!Fl.has(a))){e=Yt(l,e),l=wd(2),a=Xl(t,l,2),a!==null&&(Td(l,a,t,e),Dn(a,2),ol(a));break}}t=t.return}}function ao(e,t,l){var a=e.pingCache;if(a===null){a=e.pingCache=new ng;var s=new Set;a.set(t,s)}else s=a.get(t),s===void 0&&(s=new Set,a.set(t,s));s.has(l)||(Jr=!0,s.add(l),e=og.bind(null,e,t,l),t.then(e,e))}function og(e,t,l){var a=e.pingCache;a!==null&&a.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,Ue===e&&(ge&l)===l&&(Ve===4||Ve===3&&(ge&62914560)===ge&&300>dt()-Ri?(we&2)===0&&pn(e,0):Wr|=l,mn===ge&&(mn=0)),ol(e)}function wm(e,t){t===0&&(t=Zs()),e=ba(e,t),e!==null&&(Dn(e,t),ol(e))}function ug(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),wm(e,l)}function fg(e,t){var l=0;switch(e.tag){case 31:case 13:var a=e.stateNode,s=e.memoizedState;s!==null&&(l=s.retryLane);break;case 19:a=e.stateNode;break;case 22:a=e.stateNode._retryCache;break;default:throw Error(u(314))}a!==null&&a.delete(t),wm(e,l)}function dg(e,t){return Wt(e,t)}var Gi=null,gn=null,no=!1,$i=!1,so=!1,ea=0;function ol(e){e!==gn&&e.next===null&&(gn===null?Gi=gn=e:gn=gn.next=e),$i=!0,no||(no=!0,hg())}function hs(e,t){if(!so&&$i){so=!0;do for(var l=!1,a=Gi;a!==null;){if(e!==0){var s=a.pendingLanes;if(s===0)var r=0;else{var m=a.suspendedLanes,b=a.pingedLanes;r=(1<<31-xt(42|e)+1)-1,r&=s&~(m&~b),r=r&201326741?r&201326741|1:r?r|2:0}r!==0&&(l=!0,Cm(a,r))}else r=ge,r=Ua(a,a===Ue?r:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(r&3)===0||Ul(a,r)||(l=!0,Cm(a,r));a=a.next}while(l);so=!1}}function mg(){Tm()}function Tm(){$i=no=!1;var e=0;ea!==0&&zg()&&(e=ea);for(var t=dt(),l=null,a=Gi;a!==null;){var s=a.next,r=Am(a,t);r===0?(a.next=null,l===null?Gi=s:l.next=s,s===null&&(gn=l)):(l=a,(e!==0||(r&3)!==0)&&($i=!0)),a=s}lt!==0&<!==5||hs(e),ea!==0&&(ea=0)}function Am(e,t){for(var l=e.suspendedLanes,a=e.pingedLanes,s=e.expirationTimes,r=e.pendingLanes&-62914561;0<r;){var m=31-xt(r),b=1<<m,j=s[m];j===-1?((b&l)===0||(b&a)!==0)&&(s[m]=Vs(b,t)):j<=t&&(e.expiredLanes|=b),r&=~b}if(t=Ue,l=ge,l=Ua(e,e===t?l:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a=e.callbackNode,l===0||e===t&&(Ae===2||Ae===9)||e.cancelPendingCommit!==null)return a!==null&&a!==null&&hl(a),e.callbackNode=null,e.callbackPriority=0;if((l&3)===0||Ul(e,l)){if(t=l&-l,t===e.callbackPriority)return t;switch(a!==null&&hl(a),yc(l)){case 2:case 8:l=Cn;break;case 32:l=Da;break;case 268435456:l=pl;break;default:l=Da}return a=_m.bind(null,e),l=Wt(l,a),e.callbackPriority=t,e.callbackNode=l,t}return a!==null&&a!==null&&hl(a),e.callbackPriority=2,e.callbackNode=null,2}function _m(e,t){if(lt!==0&<!==5)return e.callbackNode=null,e.callbackPriority=0,null;var l=e.callbackNode;if(Yi()&&e.callbackNode!==l)return null;var a=ge;return a=Ua(e,e===Ue?a:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a===0?null:(um(e,a,t),Am(e,dt()),e.callbackNode!=null&&e.callbackNode===l?_m.bind(null,e):null)}function Cm(e,t){if(Yi())return null;um(e,t,!0)}function hg(){wg(function(){(we&6)!==0?Wt(Qs,mg):Tm()})}function io(){if(ea===0){var e=tn;e===0&&(e=Ra,Ra<<=1,(Ra&261888)===0&&(Ra=256)),ea=e}return ea}function Mm(e){return e==null||typeof e=="symbol"||typeof e=="boolean"?null:typeof e=="function"?e:Fs(""+e)}function km(e,t){var l=t.ownerDocument.createElement("input");return l.name=t.name,l.value=t.value,e.id&&l.setAttribute("form",e.id),t.parentNode.insertBefore(l,t),e=new FormData(e),l.parentNode.removeChild(l),e}function pg(e,t,l,a,s){if(t==="submit"&&l&&l.stateNode===s){var r=Mm((s[jt]||null).action),m=a.submitter;m&&(t=(t=m[jt]||null)?Mm(t.formAction):m.getAttribute("formAction"),t!==null&&(r=t,m=null));var b=new ti("action","action",null,a,s);e.push({event:b,listeners:[{instance:null,listener:function(){if(a.defaultPrevented){if(ea!==0){var j=m?km(s,m):new FormData(s);wr(l,{pending:!0,data:j,method:s.method,action:r},null,j)}}else typeof r=="function"&&(b.preventDefault(),j=m?km(s,m):new FormData(s),wr(l,{pending:!0,data:j,method:s.method,action:r},r,j))},currentTarget:s}]})}}for(var co=0;co<$c.length;co++){var ro=$c[co],xg=ro.toLowerCase(),gg=ro[0].toUpperCase()+ro.slice(1);Ft(xg,"on"+gg)}Ft(of,"onAnimationEnd"),Ft(uf,"onAnimationIteration"),Ft(ff,"onAnimationStart"),Ft("dblclick","onDoubleClick"),Ft("focusin","onFocus"),Ft("focusout","onBlur"),Ft(Ox,"onTransitionRun"),Ft(Dx,"onTransitionStart"),Ft(Rx,"onTransitionCancel"),Ft(df,"onTransitionEnd"),Ya("onMouseEnter",["mouseout","mouseover"]),Ya("onMouseLeave",["mouseout","mouseover"]),Ya("onPointerEnter",["pointerout","pointerover"]),Ya("onPointerLeave",["pointerout","pointerover"]),ha("onChange","change click focusin focusout input keydown keyup selectionchange".split(" ")),ha("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" ")),ha("onBeforeInput",["compositionend","keypress","textInput","paste"]),ha("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));var ps="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),bg=new Set("beforetoggle cancel close invalid load scroll scrollend toggle".split(" ").concat(ps));function Om(e,t){t=(t&4)!==0;for(var l=0;l<e.length;l++){var a=e[l],s=a.event;a=a.listeners;e:{var r=void 0;if(t)for(var m=a.length-1;0<=m;m--){var b=a[m],j=b.instance,C=b.currentTarget;if(b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}else for(m=0;m<a.length;m++){if(b=a[m],j=b.instance,C=b.currentTarget,b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}}}}function xe(e,t){var l=t[jc];l===void 0&&(l=t[jc]=new Set);var a=e+"__bubble";l.has(a)||(Dm(t,e,2,!1),l.add(a))}function oo(e,t,l){var a=0;t&&(a|=4),Dm(l,e,a,t)}var Qi="_reactListening"+Math.random().toString(36).slice(2);function uo(e){if(!e[Qi]){e[Qi]=!0,wu.forEach(function(l){l!=="selectionchange"&&(bg.has(l)||oo(l,!1,e),oo(l,!0,e))});var t=e.nodeType===9?e:e.ownerDocument;t===null||t[Qi]||(t[Qi]=!0,oo("selectionchange",!1,t))}}function Dm(e,t,l,a){switch(oh(t)){case 2:var s=Vg;break;case 8:s=Zg;break;default:s=wo}l=s.bind(null,t,l,e),s=void 0,!Cc||t!=="touchstart"&&t!=="touchmove"&&t!=="wheel"||(s=!0),a?s!==void 0?e.addEventListener(t,l,{capture:!0,passive:s}):e.addEventListener(t,l,!0):s!==void 0?e.addEventListener(t,l,{passive:s}):e.addEventListener(t,l,!1)}function fo(e,t,l,a,s){var r=a;if((t&1)===0&&(t&2)===0&&a!==null)e:for(;;){if(a===null)return;var m=a.tag;if(m===3||m===4){var b=a.stateNode.containerInfo;if(b===s)break;if(m===4)for(m=a.return;m!==null;){var j=m.tag;if((j===3||j===4)&&m.stateNode.containerInfo===s)return;m=m.return}for(;b!==null;){if(m=La(b),m===null)return;if(j=m.tag,j===5||j===6||j===26||j===27){a=r=m;continue e}b=b.parentNode}}a=a.return}Lu(function(){var C=r,R=Ac(l),B=[];e:{var M=mf.get(e);if(M!==void 0){var O=ti,W=e;switch(e){case"keypress":if(Ps(l)===0)break e;case"keydown":case"keyup":O=fx;break;case"focusin":W="focus",O=Dc;break;case"focusout":W="blur",O=Dc;break;case"beforeblur":case"afterblur":O=Dc;break;case"click":if(l.button===2)break e;case"auxclick":case"dblclick":case"mousedown":case"mousemove":case"mouseup":case"mouseout":case"mouseover":case"contextmenu":O=Yu;break;case"drag":case"dragend":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"dragstart":case"drop":O=Pp;break;case"touchcancel":case"touchend":case"touchmove":case"touchstart":O=hx;break;case of:case uf:case ff:O=lx;break;case df:O=xx;break;case"scroll":case"scrollend":O=Fp;break;case"wheel":O=bx;break;case"copy":case"cut":case"paste":O=nx;break;case"gotpointercapture":case"lostpointercapture":case"pointercancel":case"pointerdown":case"pointermove":case"pointerout":case"pointerover":case"pointerup":O=$u;break;case"toggle":case"beforetoggle":O=yx}var ie=(t&4)!==0,Re=!ie&&(e==="scroll"||e==="scrollend"),T=ie?M!==null?M+"Capture":null:M;ie=[];for(var E=C,_;E!==null;){var H=E;if(_=H.stateNode,H=H.tag,H!==5&&H!==26&&H!==27||_===null||T===null||(H=Hn(E,T),H!=null&&ie.push(xs(E,H,_))),Re)break;E=E.return}0<ie.length&&(M=new O(M,W,null,l,R),B.push({event:M,listeners:ie}))}}if((t&7)===0){e:{if(M=e==="mouseover"||e==="pointerover",O=e==="mouseout"||e==="pointerout",M&&l!==Tc&&(W=l.relatedTarget||l.fromElement)&&(La(W)||W[Ha]))break e;if((O||M)&&(M=R.window===R?R:(M=R.ownerDocument)?M.defaultView||M.parentWindow:window,O?(W=l.relatedTarget||l.toElement,O=C,W=W?La(W):null,W!==null&&(Re=d(W),ie=W.tag,W!==Re||ie!==5&&ie!==27&&ie!==6)&&(W=null)):(O=null,W=C),O!==W)){if(ie=Yu,H="onMouseLeave",T="onMouseEnter",E="mouse",(e==="pointerout"||e==="pointerover")&&(ie=$u,H="onPointerLeave",T="onPointerEnter",E="pointer"),Re=O==null?M:Un(O),_=W==null?M:Un(W),M=new ie(H,E+"leave",O,l,R),M.target=Re,M.relatedTarget=_,H=null,La(R)===C&&(ie=new ie(T,E+"enter",W,l,R),ie.target=_,ie.relatedTarget=Re,H=ie),Re=H,O&&W)t:{for(ie=vg,T=O,E=W,_=0,H=T;H;H=ie(H))_++;H=0;for(var ne=E;ne;ne=ie(ne))H++;for(;0<_-H;)T=ie(T),_--;for(;0<H-_;)E=ie(E),H--;for(;_--;){if(T===E||E!==null&&T===E.alternate){ie=T;break t}T=ie(T),E=ie(E)}ie=null}else ie=null;O!==null&&Rm(B,M,O,ie,!1),W!==null&&Re!==null&&Rm(B,Re,W,ie,!0)}}e:{if(M=C?Un(C):window,O=M.nodeName&&M.nodeName.toLowerCase(),O==="select"||O==="input"&&M.type==="file")var Ne=Fu;else if(Ju(M))if(Iu)Ne=Cx;else{Ne=Ax;var F=Tx}else O=M.nodeName,!O||O.toLowerCase()!=="input"||M.type!=="checkbox"&&M.type!=="radio"?C&&wc(C.elementType)&&(Ne=Fu):Ne=_x;if(Ne&&(Ne=Ne(e,C))){Wu(B,Ne,l,R);break e}F&&F(e,M,C),e==="focusout"&&C&&M.type==="number"&&C.memoizedProps.value!=null&&Ec(M,"number",M.value)}switch(F=C?Un(C):window,e){case"focusin":(Ju(F)||F.contentEditable==="true")&&(Za=F,qc=C,Xn=null);break;case"focusout":Xn=qc=Za=null;break;case"mousedown":Yc=!0;break;case"contextmenu":case"mouseup":case"dragend":Yc=!1,cf(B,l,R);break;case"selectionchange":if(kx)break;case"keydown":case"keyup":cf(B,l,R)}var me;if(Uc)e:{switch(e){case"compositionstart":var be="onCompositionStart";break e;case"compositionend":be="onCompositionEnd";break e;case"compositionupdate":be="onCompositionUpdate";break e}be=void 0}else Va?Zu(e,l)&&(be="onCompositionEnd"):e==="keydown"&&l.keyCode===229&&(be="onCompositionStart");be&&(Qu&&l.locale!=="ko"&&(Va||be!=="onCompositionStart"?be==="onCompositionEnd"&&Va&&(me=Bu()):(Ll=R,Mc="value"in Ll?Ll.value:Ll.textContent,Va=!0)),F=Xi(C,be),0<F.length&&(be=new Gu(be,e,null,l,R),B.push({event:be,listeners:F}),me?be.data=me:(me=Ku(l),me!==null&&(be.data=me)))),(me=Nx?Sx(e,l):zx(e,l))&&(be=Xi(C,"onBeforeInput"),0<be.length&&(F=new Gu("onBeforeInput","beforeinput",null,l,R),B.push({event:F,listeners:be}),F.data=me)),pg(B,e,C,l,R)}Om(B,t)})}function xs(e,t,l){return{instance:e,listener:t,currentTarget:l}}function Xi(e,t){for(var l=t+"Capture",a=[];e!==null;){var s=e,r=s.stateNode;if(s=s.tag,s!==5&&s!==26&&s!==27||r===null||(s=Hn(e,l),s!=null&&a.unshift(xs(e,s,r)),s=Hn(e,t),s!=null&&a.push(xs(e,s,r))),e.tag===3)return a;e=e.return}return[]}function vg(e){if(e===null)return null;do e=e.return;while(e&&e.tag!==5&&e.tag!==27);return e||null}function Rm(e,t,l,a,s){for(var r=t._reactName,m=[];l!==null&&l!==a;){var b=l,j=b.alternate,C=b.stateNode;if(b=b.tag,j!==null&&j===a)break;b!==5&&b!==26&&b!==27||C===null||(j=C,s?(C=Hn(l,r),C!=null&&m.unshift(xs(l,C,j))):s||(C=Hn(l,r),C!=null&&m.push(xs(l,C,j)))),l=l.return}m.length!==0&&e.push({event:t,listeners:m})}var yg=/\r\n?/g,jg=/\u0000|\uFFFD/g;function Um(e){return(typeof e=="string"?e:""+e).replace(yg,` +`).replace(jg,"")}function Hm(e,t){return t=Um(t),Um(e)===t}function De(e,t,l,a,s,r){switch(l){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||$a(e,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&$a(e,""+a);break;case"className":Js(e,"class",a);break;case"tabIndex":Js(e,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Js(e,l,a);break;case"style":Uu(e,a,r);break;case"data":if(t!=="object"){Js(e,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||l!=="href")){e.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){e.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof r=="function"&&(l==="formAction"?(t!=="input"&&De(e,t,"name",s.name,s,null),De(e,t,"formEncType",s.formEncType,s,null),De(e,t,"formMethod",s.formMethod,s,null),De(e,t,"formTarget",s.formTarget,s,null)):(De(e,t,"encType",s.encType,s,null),De(e,t,"method",s.method,s,null),De(e,t,"target",s.target,s,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"onClick":a!=null&&(e.onclick=gl);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"multiple":e.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":e.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){e.removeAttribute("xlink:href");break}l=Fs(""+a),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""+a):e.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""):e.removeAttribute(l);break;case"capture":case"download":a===!0?e.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,a):e.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?e.setAttribute(l,a):e.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?e.removeAttribute(l):e.setAttribute(l,a);break;case"popover":xe("beforetoggle",e),xe("toggle",e),Ks(e,"popover",a);break;case"xlinkActuate":xl(e,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":xl(e,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":xl(e,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":xl(e,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":xl(e,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":xl(e,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":xl(e,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":xl(e,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":xl(e,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Ks(e,"is",a);break;case"innerText":case"textContent":break;default:(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")&&(l=Jp.get(l)||l,Ks(e,l,a))}}function mo(e,t,l,a,s,r){switch(l){case"style":Uu(e,a,r);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"children":typeof a=="string"?$a(e,a):(typeof a=="number"||typeof a=="bigint")&&$a(e,""+a);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"onClick":a!=null&&(e.onclick=gl);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"innerHTML":case"ref":break;case"innerText":case"textContent":break;default:if(!Tu.hasOwnProperty(l))e:{if(l[0]==="o"&&l[1]==="n"&&(s=l.endsWith("Capture"),t=l.slice(2,s?l.length-7:void 0),r=e[jt]||null,r=r!=null?r[l]:null,typeof r=="function"&&e.removeEventListener(t,r,s),typeof a=="function")){typeof r!="function"&&r!==null&&(l in e?e[l]=null:e.hasAttribute(l)&&e.removeAttribute(l)),e.addEventListener(t,a,s);break e}l in e?e[l]=a:a===!0?e.setAttribute(l,""):Ks(e,l,a)}}}function ut(e,t,l){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"img":xe("error",e),xe("load",e);var a=!1,s=!1,r;for(r in l)if(l.hasOwnProperty(r)){var m=l[r];if(m!=null)switch(r){case"src":a=!0;break;case"srcSet":s=!0;break;case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,r,m,l,null)}}s&&De(e,t,"srcSet",l.srcSet,l,null),a&&De(e,t,"src",l.src,l,null);return;case"input":xe("invalid",e);var b=r=m=s=null,j=null,C=null;for(a in l)if(l.hasOwnProperty(a)){var R=l[a];if(R!=null)switch(a){case"name":s=R;break;case"type":m=R;break;case"checked":j=R;break;case"defaultChecked":C=R;break;case"value":r=R;break;case"defaultValue":b=R;break;case"children":case"dangerouslySetInnerHTML":if(R!=null)throw Error(u(137,t));break;default:De(e,t,a,R,l,null)}}ku(e,r,b,j,C,m,s,!1);return;case"select":xe("invalid",e),a=m=r=null;for(s in l)if(l.hasOwnProperty(s)&&(b=l[s],b!=null))switch(s){case"value":r=b;break;case"defaultValue":m=b;break;case"multiple":a=b;default:De(e,t,s,b,l,null)}t=r,l=m,e.multiple=!!a,t!=null?Ga(e,!!a,t,!1):l!=null&&Ga(e,!!a,l,!0);return;case"textarea":xe("invalid",e),r=s=a=null;for(m in l)if(l.hasOwnProperty(m)&&(b=l[m],b!=null))switch(m){case"value":a=b;break;case"defaultValue":s=b;break;case"children":r=b;break;case"dangerouslySetInnerHTML":if(b!=null)throw Error(u(91));break;default:De(e,t,m,b,l,null)}Du(e,a,s,r);return;case"option":for(j in l)if(l.hasOwnProperty(j)&&(a=l[j],a!=null))switch(j){case"selected":e.selected=a&&typeof a!="function"&&typeof a!="symbol";break;default:De(e,t,j,a,l,null)}return;case"dialog":xe("beforetoggle",e),xe("toggle",e),xe("cancel",e),xe("close",e);break;case"iframe":case"object":xe("load",e);break;case"video":case"audio":for(a=0;a<ps.length;a++)xe(ps[a],e);break;case"image":xe("error",e),xe("load",e);break;case"details":xe("toggle",e);break;case"embed":case"source":case"link":xe("error",e),xe("load",e);case"area":case"base":case"br":case"col":case"hr":case"keygen":case"meta":case"param":case"track":case"wbr":case"menuitem":for(C in l)if(l.hasOwnProperty(C)&&(a=l[C],a!=null))switch(C){case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,C,a,l,null)}return;default:if(wc(t)){for(R in l)l.hasOwnProperty(R)&&(a=l[R],a!==void 0&&mo(e,t,R,a,l,void 0));return}}for(b in l)l.hasOwnProperty(b)&&(a=l[b],a!=null&&De(e,t,b,a,l,null))}function Ng(e,t,l,a){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"input":var s=null,r=null,m=null,b=null,j=null,C=null,R=null;for(O in l){var B=l[O];if(l.hasOwnProperty(O)&&B!=null)switch(O){case"checked":break;case"value":break;case"defaultValue":j=B;default:a.hasOwnProperty(O)||De(e,t,O,null,a,B)}}for(var M in a){var O=a[M];if(B=l[M],a.hasOwnProperty(M)&&(O!=null||B!=null))switch(M){case"type":r=O;break;case"name":s=O;break;case"checked":C=O;break;case"defaultChecked":R=O;break;case"value":m=O;break;case"defaultValue":b=O;break;case"children":case"dangerouslySetInnerHTML":if(O!=null)throw Error(u(137,t));break;default:O!==B&&De(e,t,M,O,a,B)}}zc(e,m,b,j,C,R,r,s);return;case"select":O=m=b=M=null;for(r in l)if(j=l[r],l.hasOwnProperty(r)&&j!=null)switch(r){case"value":break;case"multiple":O=j;default:a.hasOwnProperty(r)||De(e,t,r,null,a,j)}for(s in a)if(r=a[s],j=l[s],a.hasOwnProperty(s)&&(r!=null||j!=null))switch(s){case"value":M=r;break;case"defaultValue":b=r;break;case"multiple":m=r;default:r!==j&&De(e,t,s,r,a,j)}t=b,l=m,a=O,M!=null?Ga(e,!!l,M,!1):!!a!=!!l&&(t!=null?Ga(e,!!l,t,!0):Ga(e,!!l,l?[]:"",!1));return;case"textarea":O=M=null;for(b in l)if(s=l[b],l.hasOwnProperty(b)&&s!=null&&!a.hasOwnProperty(b))switch(b){case"value":break;case"children":break;default:De(e,t,b,null,a,s)}for(m in a)if(s=a[m],r=l[m],a.hasOwnProperty(m)&&(s!=null||r!=null))switch(m){case"value":M=s;break;case"defaultValue":O=s;break;case"children":break;case"dangerouslySetInnerHTML":if(s!=null)throw Error(u(91));break;default:s!==r&&De(e,t,m,s,a,r)}Ou(e,M,O);return;case"option":for(var W in l)if(M=l[W],l.hasOwnProperty(W)&&M!=null&&!a.hasOwnProperty(W))switch(W){case"selected":e.selected=!1;break;default:De(e,t,W,null,a,M)}for(j in a)if(M=a[j],O=l[j],a.hasOwnProperty(j)&&M!==O&&(M!=null||O!=null))switch(j){case"selected":e.selected=M&&typeof M!="function"&&typeof M!="symbol";break;default:De(e,t,j,M,a,O)}return;case"img":case"link":case"area":case"base":case"br":case"col":case"embed":case"hr":case"keygen":case"meta":case"param":case"source":case"track":case"wbr":case"menuitem":for(var ie in l)M=l[ie],l.hasOwnProperty(ie)&&M!=null&&!a.hasOwnProperty(ie)&&De(e,t,ie,null,a,M);for(C in a)if(M=a[C],O=l[C],a.hasOwnProperty(C)&&M!==O&&(M!=null||O!=null))switch(C){case"children":case"dangerouslySetInnerHTML":if(M!=null)throw Error(u(137,t));break;default:De(e,t,C,M,a,O)}return;default:if(wc(t)){for(var Re in l)M=l[Re],l.hasOwnProperty(Re)&&M!==void 0&&!a.hasOwnProperty(Re)&&mo(e,t,Re,void 0,a,M);for(R in a)M=a[R],O=l[R],!a.hasOwnProperty(R)||M===O||M===void 0&&O===void 0||mo(e,t,R,M,a,O);return}}for(var T in l)M=l[T],l.hasOwnProperty(T)&&M!=null&&!a.hasOwnProperty(T)&&De(e,t,T,null,a,M);for(B in a)M=a[B],O=l[B],!a.hasOwnProperty(B)||M===O||M==null&&O==null||De(e,t,B,M,a,O)}function Lm(e){switch(e){case"css":case"script":case"font":case"img":case"image":case"input":case"link":return!0;default:return!1}}function Sg(){if(typeof performance.getEntriesByType=="function"){for(var e=0,t=0,l=performance.getEntriesByType("resource"),a=0;a<l.length;a++){var s=l[a],r=s.transferSize,m=s.initiatorType,b=s.duration;if(r&&b&&Lm(m)){for(m=0,b=s.responseEnd,a+=1;a<l.length;a++){var j=l[a],C=j.startTime;if(C>b)break;var R=j.transferSize,B=j.initiatorType;R&&Lm(B)&&(j=j.responseEnd,m+=R*(j<b?1:(b-C)/(j-C)))}if(--a,t+=8*(r+m)/(s.duration/1e3),e++,10<e)break}}if(0<e)return t/e/1e6}return navigator.connection&&(e=navigator.connection.downlink,typeof e=="number")?e:5}var ho=null,po=null;function Vi(e){return e.nodeType===9?e:e.ownerDocument}function Bm(e){switch(e){case"http://www.w3.org/2000/svg":return 1;case"http://www.w3.org/1998/Math/MathML":return 2;default:return 0}}function qm(e,t){if(e===0)switch(t){case"svg":return 1;case"math":return 2;default:return 0}return e===1&&t==="foreignObject"?0:e}function xo(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.children=="bigint"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var go=null;function zg(){var e=window.event;return e&&e.type==="popstate"?e===go?!1:(go=e,!0):(go=null,!1)}var Ym=typeof setTimeout=="function"?setTimeout:void 0,Eg=typeof clearTimeout=="function"?clearTimeout:void 0,Gm=typeof Promise=="function"?Promise:void 0,wg=typeof queueMicrotask=="function"?queueMicrotask:typeof Gm<"u"?function(e){return Gm.resolve(null).then(e).catch(Tg)}:Ym;function Tg(e){setTimeout(function(){throw e})}function ta(e){return e==="head"}function $m(e,t){var l=t,a=0;do{var s=l.nextSibling;if(e.removeChild(l),s&&s.nodeType===8)if(l=s.data,l==="/$"||l==="/&"){if(a===0){e.removeChild(s),jn(t);return}a--}else if(l==="$"||l==="$?"||l==="$~"||l==="$!"||l==="&")a++;else if(l==="html")gs(e.ownerDocument.documentElement);else if(l==="head"){l=e.ownerDocument.head,gs(l);for(var r=l.firstChild;r;){var m=r.nextSibling,b=r.nodeName;r[Rn]||b==="SCRIPT"||b==="STYLE"||b==="LINK"&&r.rel.toLowerCase()==="stylesheet"||l.removeChild(r),r=m}}else l==="body"&&gs(e.ownerDocument.body);l=s}while(l);jn(t)}function Qm(e,t){var l=e;e=0;do{var a=l.nextSibling;if(l.nodeType===1?t?(l._stashedDisplay=l.style.display,l.style.display="none"):(l.style.display=l._stashedDisplay||"",l.getAttribute("style")===""&&l.removeAttribute("style")):l.nodeType===3&&(t?(l._stashedText=l.nodeValue,l.nodeValue=""):l.nodeValue=l._stashedText||""),a&&a.nodeType===8)if(l=a.data,l==="/$"){if(e===0)break;e--}else l!=="$"&&l!=="$?"&&l!=="$~"&&l!=="$!"||e++;l=a}while(l)}function bo(e){var t=e.firstChild;for(t&&t.nodeType===10&&(t=t.nextSibling);t;){var l=t;switch(t=t.nextSibling,l.nodeName){case"HTML":case"HEAD":case"BODY":bo(l),Nc(l);continue;case"SCRIPT":case"STYLE":continue;case"LINK":if(l.rel.toLowerCase()==="stylesheet")continue}e.removeChild(l)}}function Ag(e,t,l,a){for(;e.nodeType===1;){var s=l;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!a&&(e.nodeName!=="INPUT"||e.type!=="hidden"))break}else if(a){if(!e[Rn])switch(t){case"meta":if(!e.hasAttribute("itemprop"))break;return e;case"link":if(r=e.getAttribute("rel"),r==="stylesheet"&&e.hasAttribute("data-precedence"))break;if(r!==s.rel||e.getAttribute("href")!==(s.href==null||s.href===""?null:s.href)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin)||e.getAttribute("title")!==(s.title==null?null:s.title))break;return e;case"style":if(e.hasAttribute("data-precedence"))break;return e;case"script":if(r=e.getAttribute("src"),(r!==(s.src==null?null:s.src)||e.getAttribute("type")!==(s.type==null?null:s.type)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin))&&r&&e.hasAttribute("async")&&!e.hasAttribute("itemprop"))break;return e;default:return e}}else if(t==="input"&&e.type==="hidden"){var r=s.name==null?null:""+s.name;if(s.type==="hidden"&&e.getAttribute("name")===r)return e}else return e;if(e=Vt(e.nextSibling),e===null)break}return null}function _g(e,t,l){if(t==="")return null;for(;e.nodeType!==3;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!l||(e=Vt(e.nextSibling),e===null))return null;return e}function Xm(e,t){for(;e.nodeType!==8;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!t||(e=Vt(e.nextSibling),e===null))return null;return e}function vo(e){return e.data==="$?"||e.data==="$~"}function yo(e){return e.data==="$!"||e.data==="$?"&&e.ownerDocument.readyState!=="loading"}function Cg(e,t){var l=e.ownerDocument;if(e.data==="$~")e._reactRetry=t;else if(e.data!=="$?"||l.readyState!=="loading")t();else{var a=function(){t(),l.removeEventListener("DOMContentLoaded",a)};l.addEventListener("DOMContentLoaded",a),e._reactRetry=a}}function Vt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?"||t==="$~"||t==="&"||t==="F!"||t==="F")break;if(t==="/$"||t==="/&")return null}}return e}var jo=null;function Vm(e){e=e.nextSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="/$"||l==="/&"){if(t===0)return Vt(e.nextSibling);t--}else l!=="$"&&l!=="$!"&&l!=="$?"&&l!=="$~"&&l!=="&"||t++}e=e.nextSibling}return null}function Zm(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="$"||l==="$!"||l==="$?"||l==="$~"||l==="&"){if(t===0)return e;t--}else l!=="/$"&&l!=="/&"||t++}e=e.previousSibling}return null}function Km(e,t,l){switch(t=Vi(l),e){case"html":if(e=t.documentElement,!e)throw Error(u(452));return e;case"head":if(e=t.head,!e)throw Error(u(453));return e;case"body":if(e=t.body,!e)throw Error(u(454));return e;default:throw Error(u(451))}}function gs(e){for(var t=e.attributes;t.length;)e.removeAttributeNode(t[0]);Nc(e)}var Zt=new Map,Jm=new Set;function Zi(e){return typeof e.getRootNode=="function"?e.getRootNode():e.nodeType===9?e:e.ownerDocument}var Ol=Y.d;Y.d={f:Mg,r:kg,D:Og,C:Dg,L:Rg,m:Ug,X:Lg,S:Hg,M:Bg};function Mg(){var e=Ol.f(),t=Li();return e||t}function kg(e){var t=Ba(e);t!==null&&t.tag===5&&t.type==="form"?dd(t):Ol.r(e)}var bn=typeof document>"u"?null:document;function Wm(e,t,l){var a=bn;if(a&&typeof t=="string"&&t){var s=Bt(t);s='link[rel="'+e+'"][href="'+s+'"]',typeof l=="string"&&(s+='[crossorigin="'+l+'"]'),Jm.has(s)||(Jm.add(s),e={rel:e,crossOrigin:l,href:t},a.querySelector(s)===null&&(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Og(e){Ol.D(e),Wm("dns-prefetch",e,null)}function Dg(e,t){Ol.C(e,t),Wm("preconnect",e,t)}function Rg(e,t,l){Ol.L(e,t,l);var a=bn;if(a&&e&&t){var s='link[rel="preload"][as="'+Bt(t)+'"]';t==="image"&&l&&l.imageSrcSet?(s+='[imagesrcset="'+Bt(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(s+='[imagesizes="'+Bt(l.imageSizes)+'"]')):s+='[href="'+Bt(e)+'"]';var r=s;switch(t){case"style":r=vn(e);break;case"script":r=yn(e)}Zt.has(r)||(e=y({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),Zt.set(r,e),a.querySelector(s)!==null||t==="style"&&a.querySelector(bs(r))||t==="script"&&a.querySelector(vs(r))||(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Ug(e,t){Ol.m(e,t);var l=bn;if(l&&e){var a=t&&typeof t.as=="string"?t.as:"script",s='link[rel="modulepreload"][as="'+Bt(a)+'"][href="'+Bt(e)+'"]',r=s;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":r=yn(e)}if(!Zt.has(r)&&(e=y({rel:"modulepreload",href:e},t),Zt.set(r,e),l.querySelector(s)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(vs(r)))return}a=l.createElement("link"),ut(a,"link",e),nt(a),l.head.appendChild(a)}}}function Hg(e,t,l){Ol.S(e,t,l);var a=bn;if(a&&e){var s=qa(a).hoistableStyles,r=vn(e);t=t||"default";var m=s.get(r);if(!m){var b={loading:0,preload:null};if(m=a.querySelector(bs(r)))b.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":t},l),(l=Zt.get(r))&&No(e,l);var j=m=a.createElement("link");nt(j),ut(j,"link",e),j._p=new Promise(function(C,R){j.onload=C,j.onerror=R}),j.addEventListener("load",function(){b.loading|=1}),j.addEventListener("error",function(){b.loading|=2}),b.loading|=4,Ki(m,t,a)}m={type:"stylesheet",instance:m,count:1,state:b},s.set(r,m)}}}function Lg(e,t){Ol.X(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Bg(e,t){Ol.M(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0,type:"module"},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Fm(e,t,l,a){var s=(s=fe.current)?Zi(s):null;if(!s)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=vn(l.href),l=qa(s).hoistableStyles,a=l.get(t),a||(a={type:"style",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=vn(l.href);var r=qa(s).hoistableStyles,m=r.get(e);if(m||(s=s.ownerDocument||s,m={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},r.set(e,m),(r=s.querySelector(bs(e)))&&!r._p&&(m.instance=r,m.state.loading=5),Zt.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},Zt.set(e,l),r||qg(s,e,l,m.state))),t&&a===null)throw Error(u(528,""));return m}if(t&&a!==null)throw Error(u(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=yn(l),l=qa(s).hoistableScripts,a=l.get(t),a||(a={type:"script",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function vn(e){return'href="'+Bt(e)+'"'}function bs(e){return'link[rel="stylesheet"]['+e+"]"}function Im(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function qg(e,t,l,a){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=e.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),ut(t,"link",l),nt(t),e.head.appendChild(t))}function yn(e){return'[src="'+Bt(e)+'"]'}function vs(e){return"script[async]"+e}function Pm(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var a=e.querySelector('style[data-href~="'+Bt(l.href)+'"]');if(a)return t.instance=a,nt(a),a;var s=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return a=(e.ownerDocument||e).createElement("style"),nt(a),ut(a,"style",s),Ki(a,l.precedence,e),t.instance=a;case"stylesheet":s=vn(l.href);var r=e.querySelector(bs(s));if(r)return t.state.loading|=4,t.instance=r,nt(r),r;a=Im(l),(s=Zt.get(s))&&No(a,s),r=(e.ownerDocument||e).createElement("link"),nt(r);var m=r;return m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),t.state.loading|=4,Ki(r,l.precedence,e),t.instance=r;case"script":return r=yn(l.src),(s=e.querySelector(vs(r)))?(t.instance=s,nt(s),s):(a=l,(s=Zt.get(r))&&(a=y({},l),So(a,s)),e=e.ownerDocument||e,s=e.createElement("script"),nt(s),ut(s,"link",a),e.head.appendChild(s),t.instance=s);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,Ki(a,l.precedence,e));return t.instance}function Ki(e,t,l){for(var a=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),s=a.length?a[a.length-1]:null,r=s,m=0;m<a.length;m++){var b=a[m];if(b.dataset.precedence===t)r=b;else if(r!==s)break}r?r.parentNode.insertBefore(e,r.nextSibling):(t=l.nodeType===9?l.head:l,t.insertBefore(e,t.firstChild))}function No(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.title==null&&(e.title=t.title)}function So(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.integrity==null&&(e.integrity=t.integrity)}var Ji=null;function eh(e,t,l){if(Ji===null){var a=new Map,s=Ji=new Map;s.set(l,a)}else s=Ji,a=s.get(l),a||(a=new Map,s.set(l,a));if(a.has(e))return a;for(a.set(e,null),l=l.getElementsByTagName(e),s=0;s<l.length;s++){var r=l[s];if(!(r[Rn]||r[it]||e==="link"&&r.getAttribute("rel")==="stylesheet")&&r.namespaceURI!=="http://www.w3.org/2000/svg"){var m=r.getAttribute(t)||"";m=e+m;var b=a.get(m);b?b.push(r):a.set(m,[r])}}return a}function th(e,t,l){e=e.ownerDocument||e,e.head.insertBefore(l,t==="title"?e.querySelector("head > title"):null)}function Yg(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function lh(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function Gg(e,t,l,a){if(l.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var s=vn(a.href),r=t.querySelector(bs(s));if(r){t=r._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Wi.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=r,nt(r);return}r=t.ownerDocument||t,a=Im(a),(s=Zt.get(s))&&No(a,s),r=r.createElement("link"),nt(r);var m=r;m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),l.instance=r}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=Wi.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var zo=0;function $g(e,t){return e.stylesheets&&e.count===0&&Ii(e,e.stylesheets),0<e.count||0<e.imgCount?function(l){var a=setTimeout(function(){if(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend){var r=e.unsuspend;e.unsuspend=null,r()}},6e4+t);0<e.imgBytes&&zo===0&&(zo=62500*Sg());var s=setTimeout(function(){if(e.waitingForImages=!1,e.count===0&&(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend)){var r=e.unsuspend;e.unsuspend=null,r()}},(e.imgBytes>zo?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(a),clearTimeout(s)}}:null}function Wi(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ii(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Fi=null;function Ii(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Fi=new Map,t.forEach(Qg,e),Fi=null,Wi.call(e))}function Qg(e,t){if(!(t.state.loading&4)){var l=Fi.get(e);if(l)var a=l.get(null);else{l=new Map,Fi.set(e,l);for(var s=e.querySelectorAll("link[data-precedence],style[data-precedence]"),r=0;r<s.length;r++){var m=s[r];(m.nodeName==="LINK"||m.getAttribute("media")!=="not all")&&(l.set(m.dataset.precedence,m),a=m)}a&&l.set(null,a)}s=t.instance,m=s.getAttribute("data-precedence"),r=l.get(m)||a,r===a&&l.set(null,s),l.set(m,s),this.count++,a=Wi.bind(this),s.addEventListener("load",a),s.addEventListener("error",a),r?r.parentNode.insertBefore(s,r.nextSibling):(e=e.nodeType===9?e.head:e,e.insertBefore(s,e.firstChild)),t.state.loading|=4}}var ys={$$typeof:Z,Provider:null,Consumer:null,_currentValue:te,_currentValue2:te,_threadCount:0};function Xg(e,t,l,a,s,r,m,b,j){this.tag=1,this.containerInfo=e,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=bc(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=bc(0),this.hiddenUpdates=bc(null),this.identifierPrefix=a,this.onUncaughtError=s,this.onCaughtError=r,this.onRecoverableError=m,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=j,this.incompleteTransitions=new Map}function ah(e,t,l,a,s,r,m,b,j,C,R,B){return e=new Xg(e,t,l,m,j,C,R,B,b),t=1,r===!0&&(t|=24),r=Mt(3,null,null,t),e.current=r,r.stateNode=e,t=lr(),t.refCount++,e.pooledCache=t,t.refCount++,r.memoizedState={element:a,isDehydrated:l,cache:t},ir(r),e}function nh(e){return e?(e=Wa,e):Wa}function sh(e,t,l,a,s,r){s=nh(s),a.context===null?a.context=s:a.pendingContext=s,a=Ql(t),a.payload={element:l},r=r===void 0?null:r,r!==null&&(a.callback=r),l=Xl(e,a,t),l!==null&&(Tt(l,e,t),In(l,e,t))}function ih(e,t){if(e=e.memoizedState,e!==null&&e.dehydrated!==null){var l=e.retryLane;e.retryLane=l!==0&&l<t?l:t}}function Eo(e,t){ih(e,t),(e=e.alternate)&&ih(e,t)}function ch(e){if(e.tag===13||e.tag===31){var t=ba(e,67108864);t!==null&&Tt(t,e,67108864),Eo(e,67108864)}}function rh(e){if(e.tag===13||e.tag===31){var t=Ut();t=vc(t);var l=ba(e,t);l!==null&&Tt(l,e,t),Eo(e,t)}}var Pi=!0;function Vg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=2,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function Zg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=8,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function wo(e,t,l,a){if(Pi){var s=To(a);if(s===null)fo(e,t,a,ec,l),uh(e,a);else if(Jg(s,e,t,l,a))a.stopPropagation();else if(uh(e,a),t&4&&-1<Kg.indexOf(e)){for(;s!==null;){var r=Ba(s);if(r!==null)switch(r.tag){case 3:if(r=r.stateNode,r.current.memoizedState.isDehydrated){var m=sl(r.pendingLanes);if(m!==0){var b=r;for(b.pendingLanes|=2,b.entangledLanes|=2;m;){var j=1<<31-xt(m);b.entanglements[1]|=j,m&=~j}ol(r),(we&6)===0&&(Ui=dt()+500,hs(0))}}break;case 31:case 13:b=ba(r,2),b!==null&&Tt(b,r,2),Li(),Eo(r,2)}if(r=To(a),r===null&&fo(e,t,a,ec,l),r===s)break;s=r}s!==null&&a.stopPropagation()}else fo(e,t,a,null,l)}}function To(e){return e=Ac(e),Ao(e)}var ec=null;function Ao(e){if(ec=null,e=La(e),e!==null){var t=d(e);if(t===null)e=null;else{var l=t.tag;if(l===13){if(e=h(t),e!==null)return e;e=null}else if(l===31){if(e=p(t),e!==null)return e;e=null}else if(l===3){if(t.stateNode.current.memoizedState.isDehydrated)return t.tag===3?t.stateNode.containerInfo:null;e=null}else t!==e&&(e=null)}}return ec=e,null}function oh(e){switch(e){case"beforetoggle":case"cancel":case"click":case"close":case"contextmenu":case"copy":case"cut":case"auxclick":case"dblclick":case"dragend":case"dragstart":case"drop":case"focusin":case"focusout":case"input":case"invalid":case"keydown":case"keypress":case"keyup":case"mousedown":case"mouseup":case"paste":case"pause":case"play":case"pointercancel":case"pointerdown":case"pointerup":case"ratechange":case"reset":case"resize":case"seeked":case"submit":case"toggle":case"touchcancel":case"touchend":case"touchstart":case"volumechange":case"change":case"selectionchange":case"textInput":case"compositionstart":case"compositionend":case"compositionupdate":case"beforeblur":case"afterblur":case"beforeinput":case"blur":case"fullscreenchange":case"focus":case"hashchange":case"popstate":case"select":case"selectstart":return 2;case"drag":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"mousemove":case"mouseout":case"mouseover":case"pointermove":case"pointerout":case"pointerover":case"scroll":case"touchmove":case"wheel":case"mouseenter":case"mouseleave":case"pointerenter":case"pointerleave":return 8;case"message":switch($s()){case Qs:return 2;case Cn:return 8;case Da:case Mn:return 32;case pl:return 268435456;default:return 32}default:return 32}}var _o=!1,la=null,aa=null,na=null,js=new Map,Ns=new Map,sa=[],Kg="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset".split(" ");function uh(e,t){switch(e){case"focusin":case"focusout":la=null;break;case"dragenter":case"dragleave":aa=null;break;case"mouseover":case"mouseout":na=null;break;case"pointerover":case"pointerout":js.delete(t.pointerId);break;case"gotpointercapture":case"lostpointercapture":Ns.delete(t.pointerId)}}function Ss(e,t,l,a,s,r){return e===null||e.nativeEvent!==r?(e={blockedOn:t,domEventName:l,eventSystemFlags:a,nativeEvent:r,targetContainers:[s]},t!==null&&(t=Ba(t),t!==null&&ch(t)),e):(e.eventSystemFlags|=a,t=e.targetContainers,s!==null&&t.indexOf(s)===-1&&t.push(s),e)}function Jg(e,t,l,a,s){switch(t){case"focusin":return la=Ss(la,e,t,l,a,s),!0;case"dragenter":return aa=Ss(aa,e,t,l,a,s),!0;case"mouseover":return na=Ss(na,e,t,l,a,s),!0;case"pointerover":var r=s.pointerId;return js.set(r,Ss(js.get(r)||null,e,t,l,a,s)),!0;case"gotpointercapture":return r=s.pointerId,Ns.set(r,Ss(Ns.get(r)||null,e,t,l,a,s)),!0}return!1}function fh(e){var t=La(e.target);if(t!==null){var l=d(t);if(l!==null){if(t=l.tag,t===13){if(t=h(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===31){if(t=p(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===3&&l.stateNode.current.memoizedState.isDehydrated){e.blockedOn=l.tag===3?l.stateNode.containerInfo:null;return}}}e.blockedOn=null}function tc(e){if(e.blockedOn!==null)return!1;for(var t=e.targetContainers;0<t.length;){var l=To(e.nativeEvent);if(l===null){l=e.nativeEvent;var a=new l.constructor(l.type,l);Tc=a,l.target.dispatchEvent(a),Tc=null}else return t=Ba(l),t!==null&&ch(t),e.blockedOn=l,!1;t.shift()}return!0}function dh(e,t,l){tc(e)&&l.delete(t)}function Wg(){_o=!1,la!==null&&tc(la)&&(la=null),aa!==null&&tc(aa)&&(aa=null),na!==null&&tc(na)&&(na=null),js.forEach(dh),Ns.forEach(dh)}function lc(e,t){e.blockedOn===t&&(e.blockedOn=null,_o||(_o=!0,i.unstable_scheduleCallback(i.unstable_NormalPriority,Wg)))}var ac=null;function mh(e){ac!==e&&(ac=e,i.unstable_scheduleCallback(i.unstable_NormalPriority,function(){ac===e&&(ac=null);for(var t=0;t<e.length;t+=3){var l=e[t],a=e[t+1],s=e[t+2];if(typeof a!="function"){if(Ao(a||l)===null)continue;break}var r=Ba(l);r!==null&&(e.splice(t,3),t-=3,wr(r,{pending:!0,data:s,method:l.method,action:a},a,s))}}))}function jn(e){function t(j){return lc(j,e)}la!==null&&lc(la,e),aa!==null&&lc(aa,e),na!==null&&lc(na,e),js.forEach(t),Ns.forEach(t);for(var l=0;l<sa.length;l++){var a=sa[l];a.blockedOn===e&&(a.blockedOn=null)}for(;0<sa.length&&(l=sa[0],l.blockedOn===null);)fh(l),l.blockedOn===null&&sa.shift();if(l=(e.ownerDocument||e).$$reactFormReplay,l!=null)for(a=0;a<l.length;a+=3){var s=l[a],r=l[a+1],m=s[jt]||null;if(typeof r=="function")m||mh(l);else if(m){var b=null;if(r&&r.hasAttribute("formAction")){if(s=r,m=r[jt]||null)b=m.formAction;else if(Ao(s)!==null)continue}else b=m.action;typeof b=="function"?l[a+1]=b:(l.splice(a,3),a-=3),mh(l)}}}function hh(){function e(r){r.canIntercept&&r.info==="react-transition"&&r.intercept({handler:function(){return new Promise(function(m){return s=m})},focusReset:"manual",scroll:"manual"})}function t(){s!==null&&(s(),s=null),a||setTimeout(l,20)}function l(){if(!a&&!navigation.transition){var r=navigation.currentEntry;r&&r.url!=null&&navigation.navigate(r.url,{state:r.getState(),info:"react-transition",history:"replace"})}}if(typeof navigation=="object"){var a=!1,s=null;return navigation.addEventListener("navigate",e),navigation.addEventListener("navigatesuccess",t),navigation.addEventListener("navigateerror",t),setTimeout(l,100),function(){a=!0,navigation.removeEventListener("navigate",e),navigation.removeEventListener("navigatesuccess",t),navigation.removeEventListener("navigateerror",t),s!==null&&(s(),s=null)}}}function Co(e){this._internalRoot=e}nc.prototype.render=Co.prototype.render=function(e){var t=this._internalRoot;if(t===null)throw Error(u(409));var l=t.current,a=Ut();sh(l,a,e,t,null,null)},nc.prototype.unmount=Co.prototype.unmount=function(){var e=this._internalRoot;if(e!==null){this._internalRoot=null;var t=e.containerInfo;sh(e.current,2,null,e,null,null),Li(),t[Ha]=null}};function nc(e){this._internalRoot=e}nc.prototype.unstable_scheduleHydration=function(e){if(e){var t=Su();e={blockedOn:null,target:e,priority:t};for(var l=0;l<sa.length&&t!==0&&t<sa[l].priority;l++);sa.splice(l,0,e),l===0&&fh(e)}};var ph=c.version;if(ph!=="19.2.8")throw Error(u(527,ph,"19.2.8"));Y.findDOMNode=function(e){var t=e._reactInternals;if(t===void 0)throw typeof e.render=="function"?Error(u(188)):(e=Object.keys(e).join(","),Error(u(268,e)));return e=x(t),e=e!==null?v(e):null,e=e===null?null:e.stateNode,e};var Fg={bundleType:0,version:"19.2.8",rendererPackageName:"react-dom",currentDispatcherRef:k,reconcilerVersion:"19.2.8"};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"){var sc=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!sc.isDisabled&&sc.supportsFiber)try{al=sc.inject(Fg),pt=sc}catch{}}return Es.createRoot=function(e,t){if(!f(e))throw Error(u(299));var l=!1,a="",s=Nd,r=Sd,m=zd;return t!=null&&(t.unstable_strictMode===!0&&(l=!0),t.identifierPrefix!==void 0&&(a=t.identifierPrefix),t.onUncaughtError!==void 0&&(s=t.onUncaughtError),t.onCaughtError!==void 0&&(r=t.onCaughtError),t.onRecoverableError!==void 0&&(m=t.onRecoverableError)),t=ah(e,1,!1,null,null,l,a,null,s,r,m,hh),e[Ha]=t.current,uo(e),new Co(t)},Es.hydrateRoot=function(e,t,l){if(!f(e))throw Error(u(299));var a=!1,s="",r=Nd,m=Sd,b=zd,j=null;return l!=null&&(l.unstable_strictMode===!0&&(a=!0),l.identifierPrefix!==void 0&&(s=l.identifierPrefix),l.onUncaughtError!==void 0&&(r=l.onUncaughtError),l.onCaughtError!==void 0&&(m=l.onCaughtError),l.onRecoverableError!==void 0&&(b=l.onRecoverableError),l.formState!==void 0&&(j=l.formState)),t=ah(e,1,!0,t,l??null,a,s,j,r,m,b,hh),t.context=nh(null),l=t.current,a=Ut(),a=vc(a),s=Ql(a),s.callback=null,Xl(l,s,a),l=a,t.current.lanes=l,Dn(t,l),ol(t),e[Ha]=t.current,uo(e),new nc(t)},Es.version="19.2.8",Es}var wh;function ub(){if(wh)return Oo.exports;wh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Oo.exports=ob(),Oo.exports}var Qo=ub();/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fb=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),db=i=>i.replace(/^([A-Z])|[\s-_]+(\w)/g,(c,o,u)=>u?u.toUpperCase():o.toLowerCase()),Th=i=>{const c=db(i);return c.charAt(0).toUpperCase()+c.slice(1)},fp=(...i)=>i.filter((c,o,u)=>!!c&&c.trim()!==""&&u.indexOf(c)===o).join(" ").trim(),mb=i=>{for(const c in i)if(c.startsWith("aria-")||c==="role"||c==="title")return!0};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var hb={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pb=q.forwardRef(({color:i="currentColor",size:c=24,strokeWidth:o=2,absoluteStrokeWidth:u,className:f="",children:d,iconNode:h,...p},g)=>q.createElement("svg",{ref:g,...hb,width:c,height:c,stroke:i,strokeWidth:u?Number(o)*24/Number(c):o,className:fp("lucide",f),...!d&&!mb(p)&&{"aria-hidden":"true"},...p},[...h.map(([x,v])=>q.createElement(x,v)),...Array.isArray(d)?d:[d]]));/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const He=(i,c)=>{const o=q.forwardRef(({className:u,...f},d)=>q.createElement(pb,{ref:d,iconNode:c,className:fp(`lucide-${fb(Th(i))}`,`lucide-${i}`,u),...f}));return o.displayName=Th(i),o};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const xb=[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]],Sn=He("arrow-up-right",xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gb=[["path",{d:"M2 20v-8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v8",key:"1k78r4"}],["path",{d:"M4 10V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4",key:"fb3tl2"}],["path",{d:"M12 4v6",key:"1dcgq2"}],["path",{d:"M2 18h20",key:"ajqnye"}]],bb=He("bed-double",gb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"m9 16 2 2 4-4",key:"19s6y9"}]],yb=He("calendar-check",vb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"M8 14h.01",key:"6423bh"}],["path",{d:"M12 14h.01",key:"1etili"}],["path",{d:"M16 14h.01",key:"1gbofw"}],["path",{d:"M8 18h.01",key:"lrp35t"}],["path",{d:"M12 18h.01",key:"mhygvu"}],["path",{d:"M16 18h.01",key:"kzsmim"}]],Nb=He("calendar-days",jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Sb=[["path",{d:"M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2",key:"5owen"}],["circle",{cx:"7",cy:"17",r:"2",key:"u2ysq9"}],["path",{d:"M9 17h6",key:"r8uit2"}],["circle",{cx:"17",cy:"17",r:"2",key:"axvx0g"}]],zb=He("car",Sb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Eb=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],dp=He("check",Eb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const wb=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],fc=He("chevron-down",wb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Tb=[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]],oa=He("chevron-left",Tb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ab=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],ua=He("chevron-right",Ab);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _b=[["path",{d:"M12 6v6l4 2",key:"mmk7yg"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],Cb=He("clock",_b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mb=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],kb=He("copy",Mb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ob=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],Db=He("external-link",Ob);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Rb=[["rect",{width:"20",height:"20",x:"2",y:"2",rx:"5",ry:"5",key:"2e1cvw"}],["path",{d:"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z",key:"9exkf1"}],["line",{x1:"17.5",x2:"17.51",y1:"6.5",y2:"6.5",key:"r4j83e"}]],Ub=He("instagram",Rb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hb=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],mp=He("mail",Hb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Lb=[["path",{d:"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",key:"1r0f0z"}],["circle",{cx:"12",cy:"10",r:"3",key:"ilqhr7"}]],Ls=He("map-pin",Lb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Bb=[["path",{d:"M4 5h16",key:"1tepv9"}],["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 19h16",key:"1djgab"}]],hp=He("menu",Bb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qb=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],Io=He("message-circle",qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yb=[["path",{d:"M5 12h14",key:"1ays0h"}]],Gb=He("minus",Yb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $b=[["polygon",{points:"3 11 22 2 13 21 11 13 3 11",key:"1ltx0t"}]],Ho=He("navigation",$b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qb=[["path",{d:"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",key:"9njp5v"}]],_t=He("phone",Qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Xb=[["path",{d:"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z",key:"10ikf1"}]],Vb=He("play",Xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zb=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],Kb=He("plus",Zb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jb=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],Wb=He("rotate-ccw",Jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fb=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],Ib=He("shield-check",Fb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pb=[["path",{d:"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",key:"cjf0a3"}],["path",{d:"M7 2v20",key:"1473qp"}],["path",{d:"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7",key:"j28e5"}]],e0=He("utensils",Pb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const t0=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],pp=He("x",t0);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const l0=[["path",{d:"M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",key:"1q2vi4"}],["path",{d:"m10 15 5-3-5-3z",key:"1jp15x"}]],a0=He("youtube",l0),xp=q.createContext(null);function n0({payload:i,children:c}){return n.jsx(xp.Provider,{value:i,children:c})}function le(){const i=q.useContext(xp);if(!i)throw new Error("SiteProvider 밖에서 useSite() 를 불렀습니다. payload 없이는 렌더할 수 없습니다.");return i}function s0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.6",children:[n.jsx("rect",{x:"2",y:"5",width:"20",height:"14",rx:"2"}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"8.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M8.5 9.4v5.2M5.9 12h5.2"})]}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"15.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M15.5 9.4v5.2M12.9 12h5.2"})]}),n.jsx("path",{d:"M6 19l1.5-2.4h9L18 19"})]})}function i0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M8 5v14l11-7z"})})}function c0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"})})}function r0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[n.jsx("path",{d:"M4 7h11M4 12h11M4 17h7"}),n.jsx("path",{d:"M19 17.5V9l3 1.2"}),n.jsx("circle",{cx:"17.4",cy:"17.6",r:"1.7",fill:"currentColor",stroke:"none"})]})}function o0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",children:n.jsx("path",{d:"M6 6l12 12M18 6L6 18"})})}function Ah(i){if(typeof i!="number"||!Number.isFinite(i))return"";const c=Math.floor(i/60),o=Math.floor(i%60);return`${c}:${o<10?"0":""}${o}`}function u0(){const i=le(),c=(i.songs??[]).filter($=>$.audioUrl),[o,u]=q.useState(0),[f,d]=q.useState(!1),[h,p]=q.useState(!1),[g,x]=q.useState({now:0,total:0}),[v,y]=q.useState({}),z=q.useRef(null),w=q.useRef(null),N=q.useRef(null),A=c.length;q.useEffect(()=>{A>1&&u(Math.floor(Math.random()*A))},[A]);const D=q.useCallback(()=>{var ee;const $=document.querySelector("header"),K=$==null?void 0:$.getBoundingClientRect(),G={top:`${K?K.bottom+6:66}px`};if(window.innerWidth<=640)G.left="8px",G.right="8px";else{G.left="auto";const se=(ee=w.current)==null?void 0:ee.getBoundingClientRect();G.right=`${Math.max(12,window.innerWidth-((se==null?void 0:se.right)??0))}px`}y(G)},[]);if(q.useEffect(()=>{if(!h)return;D();const $=()=>D(),K=G=>{var se,Q;const ee=G.target;(se=N.current)!=null&&se.contains(ee)||(Q=w.current)!=null&&Q.contains(ee)||p(!1)};return window.addEventListener("resize",$),window.addEventListener("scroll",$,{passive:!0}),document.addEventListener("click",K),()=>{window.removeEventListener("resize",$),window.removeEventListener("scroll",$),document.removeEventListener("click",K)}},[h,D]),q.useEffect(()=>()=>{var $;return($=z.current)==null?void 0:$.pause()},[]),c.length===0)return null;const U=c[o]??c[0],X=($=o)=>{const K=z.current,G=c[$];!K||!G||(K.getAttribute("src")!==G.audioUrl&&(K.src=G.audioUrl),K.play().then(()=>d(!0),()=>d(!1)))},Z=()=>{var $;($=z.current)==null||$.pause(),d(!1)},P=$=>{const K=($%c.length+c.length)%c.length;u(K),x({now:0,total:0}),X(K)};return n.jsxs(n.Fragment,{children:[n.jsxs("span",{id:"w4d-mini",ref:w,"data-playing":f?"1":"0",children:[n.jsx("span",{id:"w4d-tape","aria-hidden":!0,title:`${i.place.name}이(가) 만든 노래`,children:n.jsx(s0,{})}),n.jsx("span",{id:"w4d-now","aria-live":"polite",children:U.title}),n.jsx("button",{type:"button",id:"w4d-play","data-playing":f?"1":"0",title:`${f?"멈춤":"재생"} · ${U.title}`,onClick:()=>f?Z():X(),children:f?n.jsx(c0,{}):n.jsx(i0,{})}),n.jsx("button",{type:"button",id:"w4d-toggle",title:"노래 목록","aria-expanded":h,onClick:()=>p($=>!$),children:n.jsx(r0,{})})]}),n.jsx("audio",{ref:z,src:c[0].audioUrl,preload:"none",onTimeUpdate:$=>x({now:$.currentTarget.currentTime,total:$.currentTarget.duration}),onDurationChange:$=>x(K=>({...K,total:$.currentTarget.duration})),onEnded:()=>P(o+1),onError:()=>d(!1)}),n.jsxs("div",{id:"w4d-panel",ref:N,hidden:!h,style:v,children:[n.jsxs("div",{className:"w4d-head",children:[n.jsxs("span",{className:"w4d-head-t",children:[i.place.name,"이(가) 만든 노래"]}),n.jsxs("span",{className:"w4d-head-n",children:[c.length,"곡"]}),n.jsx("button",{type:"button",id:"w4d-close",title:"닫기","aria-label":"닫기",onClick:()=>p(!1),children:n.jsx(o0,{})})]}),n.jsx("ol",{children:c.map(($,K)=>n.jsx("li",{children:n.jsxs("button",{type:"button",className:"w4d-item","aria-current":K===o,"data-playing":K===o&&f?"1":"0",onClick:()=>P(K),children:[n.jsxs("span",{className:"w4d-mark",children:[n.jsx("span",{className:"w4d-disc-sm"}),n.jsxs("span",{className:"w4d-eq","aria-hidden":!0,children:[n.jsx("i",{}),n.jsx("i",{}),n.jsx("i",{})]})]}),n.jsxs("span",{className:"w4d-info",children:[n.jsx("span",{className:"w4d-t",children:$.title}),n.jsx("span",{className:"w4d-sub",children:i.place.name})]}),n.jsx("span",{className:"w4d-time",children:K===o&&(f||g.now>0)?`${Ah(g.now)}${g.total?` / ${Ah(g.total)}`:""}`:""})]})},$.songId))}),n.jsx("div",{id:"w4d-lyrics-box",hidden:!U.lyrics,children:(U.lyrics??"").replace(/^\[.*\]$/gm,"").replace(/\n{3,}/g,` + +`).trim()})]})]})}const Ma={LODGING:1,CAFE:2,RESTAURANT:3,CLINIC:4},f0={OWNER:1,CRAWL:3,TEMPLATE:5},_h={UNVERIFIED:1,VERIFIED:3,CORRECTED:4},d0=[_h.VERIFIED,_h.CORRECTED];function gp(i){return d0.includes(i)}const bt={YANOLJA:1,GOODCHOICE:2,NAVER_PLACE:3,INSTAGRAM:4,OFFICIAL_SITE:5,BLOG:6,NAVER_BOOKING:7,ETC:99},S1={PUBLISHED:3};function Bs(i){return i.filter(c=>gp(c.status)&&c.value!=null&&c.value!=="")}function bp(i){const c=new Map;for(const o of Bs(i))c.set(o.key,o);return c}function ca(i,c){const o=bp(i).get(c);return(o==null?void 0:o.value)??void 0}function At(i,c){const o=bp(i).get(c);if(!(o!=null&&o.value))return;const u=o.type==="number"?m0(o.value):o.value;return o.unit?`${u}${o.unit}`:u}function m0(i){const c=Number(i.replace(/,/g,""));return Number.isFinite(c)?c.toLocaleString("ko-KR"):i}function Po(i){return i.type!=="bool"?i.label:i.label.replace(/\s*(가능|여부)$/,"")||i.label}function h0(i){return i.filter(c=>gp(c.status)&&c.question&&c.answer).sort((c,o)=>c.sortOrder-o.sortOrder)}function eu(i){return i.map(c=>({...c,facts:Bs(c.facts)})).sort((c,o)=>c.sortOrder-o.sortOrder)}const p0={songs:"title",daily:"title",course:"name",schedule:"name",people:"name",chronicle:"title",literature:"workTitle",postcard:"line",quiz:"question",planner:"name",itinerary:"name",video:"url",event:"title"};function x0(i){const c=(i??"").trim();if(!c)return;const o=/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec(c);return o==null?void 0:o[1]}function g0(i){return/youtube\.com\/shorts\//.test((i??"").trim())}function b0(i){return`https://i.ytimg.com/vi/${i}/hqdefault.jpg`}function v0(i){return`https://www.youtube-nocookie.com/embed/${i}?autoplay=1&rel=0&modestbranding=1`}const ic={items:[],unverified:0,sourced:0};function y0(i,c){var x,v;const o=y=>{const z=i.slice(0,Math.max(0,y)),w=z.split(` +`).length,N=y-z.lastIndexOf(` +`);return`${w}번째 줄 ${N}번째 글자`},u=(x=/Unexpected token '(.)'/.exec(c))==null?void 0:x[1],f=u==="}"||u==="]"?"닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.":"그 앞의 쉼표·따옴표·괄호를 확인해 주세요.",d=/line (\d+) column (\d+)/.exec(c);if(d)return`${d[1]}번째 줄 ${d[2]}번째 글자에서 JSON 이 끊깁니다. ${f}`;const h=/position (\d+)/.exec(c);if(h)return`${o(Number(h[1]))}에서 JSON 이 끊깁니다. ${f}`;const p=(v=/\.\.\."([\s\S]*?)" is not valid JSON/.exec(c))==null?void 0:v[1],g=p?i.indexOf(p):-1;return g>=0?`${o(g+p.length)} 부근에서 JSON 이 끊깁니다. ${f}`:"JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요."}function j0(i,c){const o=p0[i],u=(c??"").trim();if(!o||!u)return ic;let f;try{f=JSON.parse(u)}catch(y){return{...ic,error:y0(u,y instanceof Error?y.message:"")}}if(typeof f!="object"||f===null||Array.isArray(f))return{...ic,error:"바깥이 { } 로 감싸인 JSON 이어야 합니다."};const d=f,h=typeof d.kind=="string"?d.kind:void 0,p=d.items;if(!Array.isArray(p))return{...ic,error:"items 배열이 없습니다. 프롬프트로 다시 만들어 주세요."};const g=p.filter(y=>typeof y=="object"&&y!==null&&!Array.isArray(y)&&typeof y[o]=="string"&&y[o].trim().length>0);let x=0,v=0;for(const y of g){const z=y;z.verified!=="확인"&&(x+=1),z.source&&typeof z.source=="object"&&(v+=1)}return{items:g,title:typeof d.title=="string"?d.title:void 0,subtitle:typeof d.subtitle=="string"?d.subtitle:void 0,linkUrl:typeof d.linkUrl=="string"&&/^https?:\/\//.test(d.linkUrl)?d.linkUrl:void 0,linkLabel:typeof d.linkLabel=="string"?d.linkLabel:void 0,kindMismatch:h&&h!==i?h:void 0,unverified:x,sourced:v}}const N0=1260,S0=60,z0="10:00",Ch=["봄","여름","가을","겨울"];function Mh(i){const c=/^(\d{1,2}):(\d{2})$/.exec(i.trim());if(!c)return;const o=Number(c[1]),u=Number(c[2]);if(!(o>23||u>59))return o*60+u}function cc(i){const c=(i%1440+1440)%1440;return`${String(Math.floor(c/60)).padStart(2,"0")}:${String(c%60).padStart(2,"0")}`}function E0(i){const c=Mh(i.startTime??"")??Mh(z0)??600,o=[];let u=c,f=0;for(const d of i.stops??[]){const h=Math.max(0,d.moveMinutes??0),p=Math.max(1,d.minutes??S0),g=u+h;if(g+p>N0){f+=1;continue}o.push({stop:d,time:cc(g),until:cc(g+p),move:h}),u=g+p}return{stops:o,from:cc(c),to:cc(u),totalMinutes:u-c,dropped:f}}function w0(i=new Date){const c=i.getMonth()+1,o=Math.floor((c-3+12)%12/3),u=Ch[o];return c%3===0&&i.getDate()<=15?[Ch[(o+3)%4],u]:[u]}function kh(i){const c=parseInt(i.replace("#",""),16);return[c>>16&255,c>>8&255,c&255]}function Oh(i,c,o){if(!/^#[0-9a-fA-F]{6}$/.test(i)||!/^#[0-9a-fA-F]{6}$/.test(c))return i;const[u,f,d]=kh(i),[h,p,g]=kh(c),x=(y,z)=>Math.round(y+(z-y)*o),v=y=>y.toString(16).padStart(2,"0");return`#${v(x(u,h))}${v(x(f,p))}${v(x(d,g))}`}function T0(i){return{surface:Oh(i.bg,i.text,.06),surfaceAlt:i.card,inverse:"#1c1917",border:Oh(i.bg,i.text,.2)}}const vp={[Ma.LODGING]:{type:"HotelRoom",path:"rooms",label:"객실"},[Ma.CAFE]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.RESTAURANT]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.CLINIC]:{type:"Product",path:"programs",label:"시술"}};function A0(i){return i.links.filter(c=>c.confirmed&&/^https?:\/\//i.test(c.url??""))}function yp(i){var c;for(const[o,u]of[["weekday_price","주중 1박"],["price","1박"],["weekend_price","주말 1박"],["peak_price","성수기 1박"]]){const f=Number((c=ca(i.facts,o))==null?void 0:c.replace(/[^0-9]/g,""));if(Number.isFinite(f)&&f>0)return{price:f,label:u}}}const Dh=[bt.NAVER_BOOKING,bt.YANOLJA,bt.GOODCHOICE,bt.NAVER_PLACE];function dc(i){return Bs(i.facts).filter(c=>c.scope==="place").map(c=>({label:Po(c),value:tu(c,i.facts)})).filter(c=>c.value!=="")}function tu(i,c){const o=At(c,i.key);return o?i.type!=="bool"?o:/여부$/.test(i.label)?o==="true"?"있음":o==="false"?"없음":o:o==="true"?"가능":o==="false"?"불가":o:""}const _0=new Set(["weekday_price","weekend_price","peak_price","price_range"]);function fl(i){const c=vp[i.place.category],o=new Map(i.media.map(u=>[u.mediaId,u]));return eu(i.units).map(u=>{const f=At(u.facts,"room_intro")??At(u.facts,"description"),d=i.place.category===Ma.LODGING?["standard_capacity","max_capacity","bed_type","room_size"]:["price","volume","origin"];return{unitId:u.unitId,slug:u.slug,name:u.name,intro:f,chips:d.map(h=>{var x;const p=At(u.facts,h),g=((x=u.facts.find(v=>v.key===h))==null?void 0:x.label)??h;return p?{label:g,value:p}:null}).filter(h=>h!==null),rows:u.facts.filter(h=>!_0.has(h.key)).map(h=>({label:Po(h),value:tu(h,u.facts)})).filter(h=>h.value!==""),images:u.mediaIds.map(h=>o.get(h)).filter(h=>{var p;return!!((p=h==null?void 0:h.alt)!=null&&p.trim())}),priceText:C0(u),prices:k0(u),href:`/${c.path}/${u.slug}`}})}function C0(i){var d,h;const c=At(i.facts,"price_range");if(c)return c;const o=Number((d=At(i.facts,"weekday_price"))==null?void 0:d.replace(/[^0-9]/g,"")),u=Number((h=At(i.facts,"price"))==null?void 0:h.replace(/[^0-9]/g,"")),f=Number.isFinite(o)&&o>0?o:u;if(!(!Number.isFinite(f)||f<=0))return`${f.toLocaleString("ko-KR")}원부터`}const M0=[["weekday_price","주중"],["weekend_price","주말"],["peak_price","성수기"]];function k0(i){return At(i.facts,"price_range")?[]:At(i.facts,"weekday_price")?M0.map(([c,o])=>({label:o,value:At(i.facts,c)??"문의"})):[]}function O0(i){const c=i??"";return/눈|설/.test(c)?"눈":/비|우|소나기/.test(c)?"비":/흐림|구름|안개|박무/.test(c)?"흐림":"맑음"}function D0(i){return i>=30?"혹서":i>=25?"더움":i>=20?"선선":i>=10?"쌀쌀":"추움"}function lu(i){return i.media.filter(c=>{var o;return((o=c.alt)==null?void 0:o.trim())&&!c.unitId})}function R0(i){return h0(i.faqs)}function U0(i){return i.theme.sections.filter(c=>c.enabled)}function Kt(i,c){var o;return((o=i.theme.sections.find(u=>u.id===c))==null?void 0:o.enabled)??!1}function H0(i,c){var u;const o=(u=i.theme.sections.find(f=>f.id===c))==null?void 0:u.body;return(o==null?void 0:o.split(/\n\s*\n/).map(f=>f.trim()).filter(Boolean))??[]}function dl(i,c){var d;const o=i.theme.sections.find(h=>h.id===c),u=j0(c,o==null?void 0:o.data),f=(d=i.local.story)==null?void 0:d[c];return!Array.isArray(f)||f.length===0?u:{...u,items:[...u.items,...f]}}function ft(i){return vp[i.place.category]}function Ye(i,c,o){var f,d;const u=(d=(f=i.theme.sections.find(h=>h.id===c))==null?void 0:f.name)==null?void 0:d.trim();return u||o}function zn(i,c){const o=new Map(Bs(i.facts).filter(u=>u.scope==="place").map(u=>[u.key,u]));return c.map(u=>o.get(u)).filter(u=>u!==void 0).map(u=>({label:Po(u),value:tu(u,i.facts)})).filter(u=>u.value!=="")}function L0(i,c){return zn(i,[c])[0]}const B0=["reservation_required","reservation_channel"];function q0(i){return zn(i,B0)}const Y0=["seat_count","terrace","room_available","group_seat_max","power_outlet","study_allowed","wheelchair_accessible"];function G0(i){return zn(i,Y0)}const $0=["operating_hours","session_times","closed_days","age_limit","guide_language"];function Q0(i){return zn(i,$0)}const X0=["check_in_time","check_out_time","operating_hours","closed_days","parking","reservation_required"];function Oa(i){return zn(i,X0).slice(0,4)}function En(i){const c=fl(i).map(u=>u.priceText).filter(u=>!!u);if(c.length===0)return;const o=u=>Number(u.replace(/[^0-9]/g,""))||Number.MAX_SAFE_INTEGER;return c.reduce((u,f)=>o(f)<o(u)?f:u)}const jp={[bt.NAVER_BOOKING]:"네이버 예약",[bt.YANOLJA]:"야놀자",[bt.GOODCHOICE]:"여기어때",[bt.NAVER_PLACE]:"네이버 플레이스",[bt.INSTAGRAM]:"인스타그램",[bt.OFFICIAL_SITE]:"공식 사이트",[bt.BLOG]:"블로그",[bt.ETC]:"채널"};function ml(i){return i.title??jp[i.channel]??"채널"}function ks(i){return jp[i.channel]??ml(i)}const V0=[bt.NAVER_PLACE,bt.INSTAGRAM];function Np(i,c){return i.links.filter(o=>o.confirmed&&c.includes(o.channel))}function Ze(i){const c=new Map(Dh.map((o,u)=>[o,u]));return Np(i,Dh).filter(o=>!Z0(o.url)).sort((o,u)=>(c.get(o.channel)??99)-(c.get(u.channel)??99))}function Z0(i){return/\/p\/search\/|[?&]query=/.test(i)}const K0=["check_in_time","check_out_time","cancel_policy","extra_person_fee","reception_hours","cooking_allowed","pet_allowed","smoking"];function J0(i){return eu(i.units).map(c=>{const o=At(c.facts,"standard_capacity"),u=At(c.facts,"max_capacity"),f=yp(c);return{unitId:c.unitId,name:c.name,capacityText:[o&&`기준 ${o}`,u&&`최대 ${u}`].filter(Boolean).join(" · ")||void 0,rateRows:["weekday_price","weekend_price","peak_price"].map(d=>{var g;const h=At(c.facts,d),p=((g=c.facts.find(x=>x.key===d))==null?void 0:g.label)??d;return h?{label:p,value:h}:null}).filter(d=>d!==null),baseRateText:f?`${f.label} ${f.price.toLocaleString("ko-KR")}원`:void 0,href:"#units"}})}function W0(i){if(i.place.category!==Ma.LODGING)return null;const c={offers:J0(i).filter(u=>u.rateRows.length>0||u.capacityText!==void 0),notices:zn(i,K0),links:Ze(i),contacts:wn(i).filter(u=>!Ze(i).some(f=>f.url===u.url)),phone:i.place.phone};return c.offers.length===0&&c.notices.length===0&&c.links.length===0&&c.contacts.length===0&&!c.phone?null:c}function F0(i,c){return i.theme.sections.some(o=>o.id===c)}function wn(i){return Np(i,V0)}function ht(i,c){const o=ks(i),u=o.endsWith("예약")?o:`${o} 예약`;return c?`${c} ${u}`:u}function Sp(i){return i.channel===bt.NAVER_BOOKING?"네이버 예약으로 바로 예약하기":`${ml(i)}에서 예약`}function I0(){var u;const i=le(),c=ft(i),o=[{label:"소개",href:"#about",show:Kt(i,"intro")},{label:c.label,href:"#units",show:i.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"축제",href:"#festival",show:(((u=i.local.festivals)==null?void 0:u.length)??0)>0},{label:"주변 정보",href:"#guide",show:Kt(i,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:i.faqs.length>0}].filter(f=>f.show);return n.jsx("header",{className:"border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 88%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsxs("a",{href:"#top",className:"flex min-w-0 items-center gap-2.5",children:[n.jsx("span",{className:"serif flex size-8 shrink-0 items-center justify-center rounded-md text-[length:var(--fs-sm)]",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",fontWeight:"var(--tpl-heading-weight, 700)"},"aria-hidden":!0,children:i.place.name.slice(0,1)}),n.jsxs("span",{className:"flex min-w-0 flex-col",children:[n.jsx("span",{className:"serif truncate text-[length:var(--fs-lead)] leading-tight",style:{fontWeight:"var(--tpl-heading-weight, 700)"},children:i.place.name}),i.place.englishName&&n.jsx("span",{className:"text-muted truncate text-[10px] uppercase leading-tight tracking-[0.14em]",children:i.place.englishName})]})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-7 lg:flex",children:o.map(f=>n.jsx("a",{href:f.href,className:"text-muted py-1 text-[length:var(--fs-sm)] font-medium transition-colors hover:text-current",children:f.label},f.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1.5",children:[n.jsx(u0,{}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap inline-flex items-center gap-1.5 rounded-lg px-3 text-[length:var(--fs-xs)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{className:"hidden sm:inline",children:i.place.phone}),n.jsx("span",{className:"sr-only sm:hidden",children:"전화"})]}),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-lg marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line tpl-border absolute right-0 top-[calc(100%+0.5rem)] z-50 w-52 overflow-hidden rounded-xl border shadow-lg",style:{backgroundColor:"var(--tpl-surface, #fff)"},children:n.jsx("ul",{className:"divide-line divide-y",children:o.map(f=>n.jsx("li",{children:n.jsx("a",{href:f.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-medium",children:f.label})},f.label))})})]})]})]})})}function mc(i){return i.media.find(c=>{var o;return c.isPrimary&&((o=c.alt)==null?void 0:o.trim())})??i.media.find(c=>{var o;return(o=c.alt)==null?void 0:o.trim()})}function P0(i){return ev(i).join(" ")}function ev(i){const{place:c,facts:o}=i,u=[],f=c.addressLocality??c.addressRegion??c.roadAddress??c.address;u.push(f?`${f}에 있는 ${c.name}입니다.`:`${c.name}입니다.`);const d=ca(o,"check_in_time"),h=ca(o,"check_out_time");d&&h&&u.push(`체크인 ${d}, 체크아웃 ${h}.`);const p=ca(o,"business_hours")??ca(o,"open_hours");p&&u.push(`영업시간 ${p}.`);const g=ca(o,"pet_allowed");if(g==="false"&&u.push("반려동물 동반 불가."),g==="true"&&u.push("반려동물 동반 가능."),ca(o,"parking")==="true"){const v=ca(o,"parking_capacity");u.push(v?`주차 ${v}대 가능.`:"주차 가능.")}return c.phone&&u.push(`문의 ${c.phone}.`),u}const tv=6e3,lv=5;function av(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=c.addressLocality??c.addressRegion,p=[...i.media].filter(N=>{var A;return(A=N.alt)==null?void 0:A.trim()}).sort((N,A)=>Number(A.isPrimary)-Number(N.isPrimary)).slice(0,lv),[g,x]=q.useState(0),[v,y]=q.useState(!1),z=q.useRef(null),w=q.useCallback(N=>x(A=>(A+N+p.length)%p.length),[p.length]);return q.useEffect(()=>{if(v||p.length<2)return;const N=setInterval(()=>w(1),tv);return()=>clearInterval(N)},[v,w,p.length]),n.jsxs("section",{id:"top",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"clamp(24rem, 76vh, 42rem)"},onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),onFocusCapture:()=>y(!0),onBlurCapture:()=>y(!1),onTouchStart:N=>{z.current=N.touches[0].clientX},onTouchEnd:N=>{const A=z.current;if(z.current=null,A===null)return;const D=N.changedTouches[0].clientX-A;Math.abs(D)>40&&w(D<0?1:-1)},children:[p.map((N,A)=>n.jsx("img",{src:N.url,alt:N.alt,fetchPriority:A===0?"high":"low",loading:A===0?void 0:"lazy",decoding:"async",className:"absolute inset-0 size-full object-cover object-center transition-opacity duration-700",style:{opacity:A===g?1:0}},N.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 45%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 60%, transparent) 100%)"}}),n.jsxs("div",{className:"pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-6 text-center",children:[h&&n.jsx("p",{className:"label opacity-85",children:h}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),(o.tagline??o.heroSubline)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90",children:o.tagline??o.heroSubline})]}),p.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Rh,{dir:"prev",onClick:()=>w(-1)}),n.jsx(Rh,{dir:"next",onClick:()=>w(1)}),n.jsx("ul",{className:"absolute inset-x-0 bottom-5 flex items-center justify-center gap-2",children:p.map((N,A)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>x(A),"aria-label":`${A+1}번째 사진`,"aria-current":A===g,className:`h-1.5 rounded-full bg-current transition-all ${A===g?"w-7 opacity-90":"w-1.5 opacity-45 hover:opacity-70"}`})},N.mediaId))})]})]}),(f||u.length>0||d.length>0||c.phone)&&n.jsx("div",{className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsxs("div",{className:"shell flex flex-col items-center gap-4 py-4 lg:flex-row lg:justify-between lg:gap-8",children:[n.jsxs("dl",{className:"divide-line flex flex-wrap justify-center gap-y-2 sm:divide-x",children:[f&&n.jsxs("div",{className:"px-5 text-center sm:first:pl-0",children:[n.jsx("dt",{className:"label",children:"1박 최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:f})]}),u.map(N=>n.jsxs("div",{className:"px-5 text-center",children:[n.jsx("dt",{className:"label",children:N.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:N.value})]},N.label))]}),n.jsxs("div",{className:"flex shrink-0 gap-2",children:[c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border inline-flex items-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:c.phone})]}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]})]})})]})}function Rh({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full bg-current/15 px-3 backdrop-blur-sm transition-colors hover:bg-current/25 sm:inline-flex ${i==="prev"?"left-4":"right-4"}`,children:n.jsx(o,{className:"size-5"})})}function nv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i).slice(0,3),f=En(i),d=Ze(i),h=L0(i,"extra_person_fee"),p=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary))[0],g=c.addressLocality??c.addressRegion;return n.jsx("section",{id:"top",className:"border-line w-full border-b",children:n.jsxs("div",{className:"grid lg:grid-cols-2",children:[n.jsx("div",{className:"relative order-1 min-h-[16rem] lg:order-none lg:min-h-[34rem]",children:p&&n.jsx("img",{src:p.url,alt:p.alt,fetchPriority:"high",decoding:"async",className:"absolute inset-0 size-full object-cover"})}),n.jsxs("div",{className:"order-2 flex flex-col justify-center gap-6 px-6 py-10 sm:px-10 lg:order-none lg:px-14 lg:py-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:[n.jsxs("div",{children:[g&&n.jsx("p",{className:"label",children:g}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80",children:o.tagline??o.heroSubline??o.summary})]}),n.jsxs("div",{className:"panel p-5 sm:p-6",children:[f&&n.jsxs("p",{className:"flex items-baseline gap-2",children:[n.jsx("span",{className:"text-[length:var(--fs-h3)] font-bold tabular-nums",children:f}),n.jsx("span",{className:"text-muted text-[length:var(--fs-xs)]",children:"1박 기준"})]}),u.length>0&&n.jsxs("dl",{className:"divide-line mt-4 divide-y border-t pt-1",children:[u.map(x=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{className:"font-semibold",children:x.value})]},x.label)),h&&n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:h.label}),n.jsx("dd",{className:"font-semibold tabular-nums",children:h.value})]})]}),n.jsxs("div",{className:"mt-5 flex flex-col gap-2 sm:flex-row",children:[d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])}),c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:c.phone})]})]})]})]})]})})}const sv={"stay-reservation":"reservation","stay-oasi":"oasi","stay-studio":"studio","stay-pastel":"pastel","stay-editorial":"editorial"};function zp(i){return sv[i??""]??"default"}function hc(){return zp(le().theme.templateId)}const rc=5,Xo=1.8,iv=3;function cv(i){const c=i*rc,o=f=>`${(f/c*100).toFixed(3)}%`;return`${i<2?"":` +.stay2-hero-slide { animation: stay2-hero-fade ${c}s ease-in-out infinite; } +@keyframes stay2-hero-fade { + 0% { opacity: 1; } + ${o(rc)} { opacity: 1; } + ${o(rc+Xo)} { opacity: 0; } + ${o(c-Xo)} { opacity: 0; } + 100% { opacity: 1; } +}`} +.stay2-hero-focus { animation: stay2-hero-focus 1.6s cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes stay2-hero-focus { + 0% { opacity: 0; filter: blur(0.22em); } + 40% { opacity: 1; } + 100% { opacity: 1; filter: blur(0); } +} +@media (prefers-reduced-motion: reduce) { + .stay2-hero-slide, .stay2-hero-focus { animation: none; } +} +`}function rv(i,c){return((c-i)%c*rc+Xo).toFixed(1)}function ov(){const i=le(),{place:c,narrative:o}=i,u=ft(i),f=Ze(i)[0],d=o.tagline??o.heroSubline,h=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)),p=h.length<2?0:Math.min(h.length,iv),g=f?{href:f.url,label:`${ks(f)}에서 예약`,external:!0}:c.phone?{href:`tel:${c.phone}`,label:`${c.phone} 예약 문의`,external:!1}:i.units.length>0?{href:"#units",label:`${u.label} 보기`,external:!1}:null;return n.jsxs("section",{id:"top",className:"w-full",children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:cv(p)}}),n.jsxs("div",{className:"relative flex w-full items-center justify-center overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"100svh"},children:[h.map((x,v)=>n.jsx("img",{src:x.url,alt:x.alt,fetchPriority:v===0?"high":v<p?"auto":"low",loading:v<Math.max(p,1)?void 0:"lazy",decoding:"async",width:x.width,height:x.height,className:`absolute inset-0 size-full object-cover object-center ${v<p?"stay2-hero-slide":""}`,style:{opacity:v===0?1:0,animationDelay:v<p?`-${rv(v,p)}s`:void 0}},x.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 14%, transparent) 40%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 h-48",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 68%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 40%, transparent) 45%, transparent 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"radial-gradient(ellipse 58% 42% at 50% 52%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 38%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 18%, transparent) 55%, transparent 78%)"}}),n.jsxs("div",{className:"shell relative z-10 flex flex-col items-center pt-20 text-center",style:{textShadow:"0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)"},children:[n.jsx("h1",{className:"stay2-hero-focus serif",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.06em",lineHeight:1.25,animationDelay:"0.05s"},children:c.name}),d&&n.jsx("p",{className:"stay2-hero-focus measure mt-6 text-[length:var(--fs-lead)] font-light leading-loose opacity-95",style:{animationDelay:"0.3s"},children:d}),g&&n.jsx("a",{href:g.href,...g.external?{target:"_blank",rel:"noopener noreferrer"}:{},className:"stay2-hero-focus tap mt-10 inline-flex items-center justify-center border border-current/70 px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-colors hover:border-current",style:{animationDelay:"0.55s",backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 22%, transparent)",boxShadow:"0 1px 6px color-mix(in srgb, var(--tpl-inverse, #1c1917) 30%, transparent)"},children:g.label})]})]})]})}function uv(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=En(i),d=Ze(i),h=Oa(i),p=h.length>0?h:dc(i).slice(0,4),g=(u==null?void 0:u.caption)??c.name;return n.jsxs("section",{id:"top","data-oasi":!0,"aria-labelledby":"top-heading",className:"pt-12 lg:pt-16",children:[n.jsx("h1",{id:"top-heading",className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em] leading-[1.9]",children:c.name}),o.heroHeadline&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o.heroHeadline}),u&&n.jsxs("figure",{className:"relative mx-auto mt-10 w-full max-w-[34rem]",children:[n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"block h-[clamp(17rem,27vw,24rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:g})]}),(f||p.length>0)&&n.jsxs("dl",{className:"border-line mt-10 flex flex-wrap gap-x-10 gap-y-4 border-t pt-5 text-[length:var(--fs-xs)] leading-[1.9]",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:"최저가"}),n.jsx("dd",{children:f})]}),p.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{children:x.value})]},x.label))]}),d.length>0&&n.jsx("ul",{className:"mt-6 flex flex-wrap items-center gap-x-8",children:d.map(x=>n.jsx("li",{children:n.jsx("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(x)})},x.url))})]})}function fv(){const i=le(),c=lu(i),o=mc(i),u=o&&c.some(f=>f.mediaId===o.mediaId)?[o,...c.filter(f=>f.mediaId!==o.mediaId)]:c;return u.length===0?null:n.jsx("section",{id:"top","aria-label":`${i.place.name} 사진`,className:"w-full",children:n.jsx("div",{className:"columns-1 gap-2 sm:columns-2",children:u.map((f,d)=>n.jsx("figure",{className:"mb-2 break-inside-avoid",children:n.jsx("img",{src:f.url,alt:f.alt,loading:d===0?"eager":"lazy",fetchPriority:d===0?"high":void 0,decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})})}function dv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=o.tagline??o.summary,p=c.addressLocality??c.addressRegion,g=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)).slice(0,3);return n.jsxs("section",{id:"top","data-pastel":"own",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--pastel-1)"},children:[n.jsxs("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-0",children:[n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(12rem, 26vw, 22rem)",aspectRatio:"1",backgroundColor:"var(--pastel-2)",right:"-6%",top:"-6%",opacity:.75}}),n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(8rem, 18vw, 15rem)",aspectRatio:"1",backgroundColor:"var(--pastel-3)",left:"46%",bottom:"-10%",opacity:.7}})]}),n.jsxs("div",{className:"shell relative grid items-center gap-8 lg:grid-cols-2 lg:gap-14",style:{paddingBlock:"var(--section-space)"},children:[n.jsxs("div",{className:"order-2 lg:order-1",children:[p&&n.jsx("p",{className:"inline-flex items-center rounded-full px-3 py-1 text-[length:var(--fs-xs)] font-extrabold",style:{backgroundColor:"var(--tpl-bg, #fff)",color:"var(--pastel-ink)"},children:p}),n.jsx("h1",{className:"mt-4 break-keep",style:{fontSize:"var(--fs-display)",fontWeight:800,lineHeight:1.2,letterSpacing:"var(--tpl-heading-tracking, -0.03em)"},children:c.name}),h&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80",children:h}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]}),n.jsxs("div",{className:"order-1 lg:order-2",children:[g[0]&&n.jsx("div",{className:"relative w-full overflow-hidden",style:{aspectRatio:"1 / 1",borderRadius:"calc(var(--tpl-radius, 24px) * 1.6)",boxShadow:"0 0 0 clamp(6px, 1.4vw, 12px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:g[0].url,alt:g[0].alt,fetchPriority:"high",decoding:"async",className:"size-full object-cover"})}),g.length>1&&n.jsx("ul",{className:"mt-3 grid grid-cols-2 gap-3",children:g.slice(1).map(x=>n.jsx("li",{className:"relative overflow-hidden",style:{aspectRatio:"4 / 3",borderRadius:"var(--tpl-radius, 24px)",boxShadow:"0 0 0 clamp(4px, 1vw, 8px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:x.url,alt:x.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},x.mediaId))})]})]})]}),(f||u.length>0)&&n.jsx("div",{className:"w-full",style:{backgroundColor:"var(--pastel-2)"},children:n.jsxs("dl",{className:"shell flex flex-wrap items-center justify-center gap-x-12 gap-y-5 py-8 text-center",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:"최저가"}),n.jsx("dd",{className:"mt-1 font-extrabold tabular-nums",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:f})]}),u.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:x.label}),n.jsx("dd",{className:"mt-1 font-extrabold",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:x.value})]},x.label))]})})]})}const Os="repeating-linear-gradient(90deg, color-mix(in oklab, currentColor 3%, transparent) 0 1px, transparent 1px 6px),repeating-linear-gradient(0deg, color-mix(in oklab, currentColor 2%, transparent) 0 1px, transparent 1px 6px)",mv={intro:"about",info:"info",rooms:"units",menu:"units",programs:"units",pricing:"pricing",booking:"booking",space:"space",inquiry:"inquiry",exhibition:"exhibition",photos:"gallery",local:"guide",weather:"weather",map:"location",faq:"faq",songs:"songs",daily:"daily",chronicle:"chronicle",people:"people",quiz:"quiz",postcard:"postcard",video:"video",itinerary:"itinerary"},hv={about:"소개",info:"이용 정보",pricing:"요금",booking:"예약 안내",space:"공간",inquiry:"문의",exhibition:"관람 안내",gallery:"사진",guide:"주변",weather:"날씨",location:"오시는 길",faq:"자주 묻는 질문",songs:"노래",daily:"일력",chronicle:"연표",people:"인물",quiz:"퀴즈",postcard:"엽서",video:"영상",itinerary:"일정"};function Uh(i){return String(i).padStart(2,"0")}function Ep(){var f;const i=le(),c=ft(i),o=new Set,u=[];for(const d of U0(i)){const h=mv[d.id];if(!h||o.has(h)||h==="units"&&i.units.length===0)continue;o.add(h);const p=(f=d.name)==null?void 0:f.trim();u.push({no:Uh(u.length+1),label:p||hv[h]||c.label,anchor:h})}return o.has("location")||u.push({no:Uh(u.length+1),label:"오시는 길",anchor:"location"}),u}function au({id:i,title:c,lead:o,aside:u}){var d;const f=(d=Ep().find(h=>h.anchor===i))==null?void 0:d.no;return n.jsxs("header",{className:"mb-7 sm:mb-10",children:[n.jsx("div",{"aria-hidden":!0,className:"mb-5 h-0.5 w-full bg-current opacity-75"}),n.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-10",children:[n.jsxs("div",{className:"relative min-w-0 flex-1",children:[f&&n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 select-none leading-none tabular-nums",style:{fontSize:"clamp(2.5rem, 7vw, 5rem)",fontWeight:800,opacity:.12},children:f}),n.jsx("h2",{id:`${i}-heading`,className:"h2 relative pl-9 sm:pl-14 xl:pl-20",style:{fontWeight:800},children:c}),o&&n.jsx("p",{className:"text-muted measure relative mt-2 pl-9 font-serif text-[length:var(--fs-sm)] leading-relaxed sm:pl-14 xl:pl-20",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})]})}function pv(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=ft(i),d=fl(i),h=Oa(i),p=En(i),g=Ze(i)[0],x=wn(i)[0],v=o.tagline??o.heroSubline,z=[c.addressSubLocality??c.addressLocality??c.addressRegion,d.length>0?`${f.label} ${d.length}개`:void 0,p].filter(w=>!!w);return n.jsx("section",{id:"top",className:"relative w-full",style:{backgroundImage:Os},children:n.jsxs("div",{className:"grid grid-cols-12",children:[u&&n.jsx("div",{className:"relative col-span-12 xl:col-span-8 xl:col-start-5 xl:row-start-1",style:{minHeight:"clamp(15rem, 52vh, 34rem)",backgroundColor:"var(--tpl-inverse)"},children:n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"absolute inset-0 size-full object-cover"})}),n.jsx("div",{className:"z-10 col-span-12 xl:col-start-1 xl:row-start-1 xl:mb-12 xl:mr-16 xl:self-end",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse) 88%, transparent)",color:"var(--tpl-bg)"},children:n.jsx("h1",{className:"serif px-4 py-5 sm:px-6 xl:py-8 xl:pl-10 xl:pr-16",style:{fontSize:"clamp(2.25rem, 7.5vw, 5.5rem)",fontWeight:800,lineHeight:.98,letterSpacing:"-0.035em"},children:c.name})}),n.jsxs("div",{className:"col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8",children:[v&&n.jsx("p",{className:"measure font-serif text-[length:var(--fs-lead)] leading-relaxed",children:v}),z.length>0&&n.jsx("p",{className:"text-[length:var(--fs-sm)] font-semibold tabular-nums",children:z.join(" · ")}),(g||c.phone||x)&&n.jsxs("div",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(g)}),c.phone&&n.jsx("a",{href:`tel:${c.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:c.phone}),x&&n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:[ml(x)," 문의"]})]}),h.length>0&&n.jsx("dl",{className:"mt-1",children:h.map(w=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-4 border-t py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:w.label}),n.jsx("dd",{className:"text-right font-semibold",children:w.value})]},w.label))})]})]})})}function xv(){var g;const i=le(),c=hc();if(c==="reservation")return n.jsx(ov,{});if(c==="oasi")return n.jsx(uv,{});if(c==="studio")return n.jsx(fv,{});if(c==="pastel")return n.jsx(dv,{});if(c==="editorial")return n.jsx(pv,{});const o=(g=i.theme.sections.find(x=>x.id==="hero"))==null?void 0:g.variantId;if(o==="hero.slideshow")return n.jsx(av,{});if(o==="hero.split")return n.jsx(nv,{});const{place:u,narrative:f}=i,d=mc(i),h=ft(i);Ze(i);const p=u.addressLocality??u.addressRegion;return n.jsx("section",{id:"top",className:"w-full",children:n.jsxs("div",{className:"relative flex w-full items-end overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",minHeight:"clamp(24rem, 62vh, 36rem)"},children:[d&&n.jsxs("div",{className:"absolute inset-0 z-0",children:[n.jsx("img",{src:d.url,alt:d.alt,fetchPriority:"high",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover object-center"}),n.jsx("div",{className:"absolute inset-0",style:{background:"linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)"}})]}),n.jsxs("div",{className:"shell relative z-10 pb-10 pt-24 sm:pb-12",children:[p&&n.jsxs("p",{className:"mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80",children:[n.jsx(Ls,{className:"size-3.5"}),n.jsx("span",{children:p})]}),n.jsx("h1",{className:"serif",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:u.name}),(f.tagline??f.heroSubline??f.summary)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85",children:f.tagline??f.heroSubline??f.summary}),i.units.length>0&&n.jsxs("a",{href:"#units",className:"mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:[n.jsxs("span",{children:[h.label," 보기"]}),n.jsx(fc,{className:"size-4"})]})]})]})})}function nu(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":`${c.getFullYear()}년 ${c.getMonth()+1}월 ${c.getDate()}일`}function qs(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":c.toLocaleDateString("sv-SE",{timeZone:"Asia/Seoul"})}function Vo(i){return`https://search.naver.com/search.naver?query=${encodeURIComponent(i)}`}function gv(i){return`https://www.youtube.com/results?search_query=${encodeURIComponent(i)}`}function su(i){return i.replace(/,/g," ").replace(/\s+/g," ").trim()}function bv(i,c,o){return c==null||o==null?`https://map.kakao.com/link/search/${encodeURIComponent(i)}`:`https://map.kakao.com/link/to/${encodeURIComponent(su(i))},${c},${o}`}function vv(i,c){const o=u=>`${u.lng},${u.lat},${encodeURIComponent(su(u.name))}`;return`https://map.naver.com/p/directions/${o(i)}/${o(c)}/-/car`}function wp(i,c,o){return c==null||o==null?`https://map.naver.com/p/search/${encodeURIComponent(i)}`:`https://map.naver.com/p/directions/-/${o},${c},${encodeURIComponent(su(i))}/-/transit`}function yv(i,c,o=16/9,u=.0022){const f=Math.max(Math.cos(i*Math.PI/180),.01),d=o*u/f;return`https://www.openstreetmap.org/export/embed.html?bbox=${[c-d,i-u,c+d,i+u].map(p=>p.toFixed(6)).join(",")}&layer=mapnik&marker=${i},${c}`}function jv(i,c,o){if(c==null||o==null)return;const u=encodeURIComponent(i);return`tmap://route?goalname=${u}&goalx=${o}&goaly=${c}&rGoName=${u}&rGoX=${o}&rGoY=${c}`}function Hh(){const i=le(),c=dc(i).slice(0,6),o=i.narrative.summary??P0(i);return c.length===0&&!o?null:n.jsx("section",{id:"summary","aria-labelledby":"summary-heading",className:"border-line w-full border-b py-10 sm:py-14",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsx("div",{className:"shell",children:n.jsxs("div",{className:"grid gap-8 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h2",{id:"summary-heading",className:"h3",children:"예약 전 확인"}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed",children:o}),n.jsxs("p",{className:"text-muted mt-3 text-[length:var(--fs-xs)]",children:[nu(i.site.updatedAt)," 기준, 사업자가 확인한 정보입니다."]})]}),c.length>0&&n.jsx("dl",{className:"grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2",children:c.map(u=>n.jsxs("div",{className:"border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28",children:u.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:u.value})]},u.label))})]})})})}function iu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-10 flex flex-col items-center gap-4 text-center sm:mb-14",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif max-w-full",style:{fontSize:"var(--fs-h2)",fontWeight:300,letterSpacing:"0.04em",lineHeight:1.35},children:c}),n.jsx("i",{"aria-hidden":!0,className:"block h-px w-10 bg-current opacity-25"}),o&&n.jsx("p",{className:"text-muted measure text-[length:var(--fs-sm)] leading-loose",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-xs)]",children:u})]})}function cu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-8",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:["[",c,"]"]}),o&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})}function ru({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-7 sm:mb-10",children:n.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-10",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif min-w-0 font-extrabold",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.01em",lineHeight:1.2,color:"var(--tpl-text)"},children:c}),o&&n.jsx("p",{className:"text-muted measure mt-2 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})})}function ou({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col items-center gap-3 text-center sm:mb-10",children:[n.jsx("i",{"aria-hidden":!0,className:"block h-[6px] w-10 rounded-full",style:{backgroundColor:"var(--pastel-2, currentColor)",opacity:.9}}),n.jsx("h2",{id:`${i}-heading`,className:"h2 break-keep",style:{fontSize:"calc(var(--fs-h2) * 1.15)",fontWeight:800},children:c}),o&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-65",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-sm)]",children:u})]})}const Nv={base:{backgroundColor:"var(--tpl-surface, #ffffff)"},alt:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},dark:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"}};function vt({id:i,title:c,lead:o,tone:u="base",aside:f,footnote:d,children:h,wide:p,bare:g}){const x=hc(),v=x==="reservation"?iu:x==="oasi"?cu:x==="studio"?ru:x==="pastel"?ou:x==="editorial"?au:Sv;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{...Nv[u],paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:p?"w-full":"shell",children:[!g&&n.jsx("div",{className:p?"shell":void 0,children:n.jsx(v,{id:i,title:c,lead:o,aside:f})}),h,d&&n.jsx("p",{className:`text-muted mt-5 text-[length:var(--fs-xs)] ${p?"shell":""}`,children:d})]})})}function Sv({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-6 sm:mb-8",children:n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"h2 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:c})]}),o&&n.jsx("p",{className:"text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})})}function zv(i){return Object.prototype.toString.call(i)==="[object Object]"}function Lh(i){return zv(i)||Array.isArray(i)}function Ev(){return!!(typeof window<"u"&&window.document&&window.document.createElement)}function uu(i,c){const o=Object.keys(i),u=Object.keys(c);if(o.length!==u.length)return!1;const f=JSON.stringify(Object.keys(i.breakpoints||{})),d=JSON.stringify(Object.keys(c.breakpoints||{}));return f!==d?!1:o.every(h=>{const p=i[h],g=c[h];return typeof p=="function"?`${p}`==`${g}`:!Lh(p)||!Lh(g)?p===g:uu(p,g)})}function Bh(i){return i.concat().sort((c,o)=>c.name>o.name?1:-1).map(c=>c.options)}function wv(i,c){if(i.length!==c.length)return!1;const o=Bh(i),u=Bh(c);return o.every((f,d)=>{const h=u[d];return uu(f,h)})}function fu(i){return typeof i=="number"}function Zo(i){return typeof i=="string"}function pc(i){return typeof i=="boolean"}function qh(i){return Object.prototype.toString.call(i)==="[object Object]"}function $e(i){return Math.abs(i)}function du(i){return Math.sign(i)}function As(i,c){return $e(i-c)}function Tv(i,c){if(i===0||c===0||$e(i)<=$e(c))return 0;const o=As($e(i),$e(c));return $e(o/i)}function Av(i){return Math.round(i*100)/100}function Ds(i){return Rs(i).map(Number)}function el(i){return i[Ys(i)]}function Ys(i){return Math.max(0,i.length-1)}function mu(i,c){return c===Ys(i)}function Yh(i,c=0){return Array.from(Array(i),(o,u)=>c+u)}function Rs(i){return Object.keys(i)}function Tp(i,c){return[i,c].reduce((o,u)=>(Rs(u).forEach(f=>{const d=o[f],h=u[f],p=qh(d)&&qh(h);o[f]=p?Tp(d,h):h}),o),{})}function Ko(i,c){return typeof c.MouseEvent<"u"&&i instanceof c.MouseEvent}function _v(i,c){const o={start:u,center:f,end:d};function u(){return 0}function f(g){return d(g)/2}function d(g){return c-g}function h(g,x){return Zo(i)?o[i](g):i(c,g,x)}return{measure:h}}function Us(){let i=[];function c(f,d,h,p={passive:!0}){let g;if("addEventListener"in f)f.addEventListener(d,h,p),g=()=>f.removeEventListener(d,h,p);else{const x=f;x.addListener(h),g=()=>x.removeListener(h)}return i.push(g),u}function o(){i=i.filter(f=>f())}const u={add:c,clear:o};return u}function Cv(i,c,o,u){const f=Us(),d=1e3/60;let h=null,p=0,g=0;function x(){f.add(i,"visibilitychange",()=>{i.hidden&&N()})}function v(){w(),f.clear()}function y(D){if(!g)return;h||(h=D,o(),o());const U=D-h;for(h=D,p+=U;p>=d;)o(),p-=d;const X=p/d;u(X),g&&(g=c.requestAnimationFrame(y))}function z(){g||(g=c.requestAnimationFrame(y))}function w(){c.cancelAnimationFrame(g),h=null,p=0,g=0}function N(){h=null,p=0}return{init:x,destroy:v,start:z,stop:w,update:o,render:u}}function Mv(i,c){const o=c==="rtl",u=i==="y",f=u?"y":"x",d=u?"x":"y",h=!u&&o?-1:1,p=v(),g=y();function x(N){const{height:A,width:D}=N;return u?A:D}function v(){return u?"top":o?"right":"left"}function y(){return u?"bottom":o?"left":"right"}function z(N){return N*h}return{scroll:f,cross:d,startEdge:p,endEdge:g,measureSize:x,direction:z}}function ka(i=0,c=0){const o=$e(i-c);function u(x){return x<i}function f(x){return x>c}function d(x){return u(x)||f(x)}function h(x){return d(x)?u(x)?i:c:x}function p(x){return o?x-o*Math.ceil((x-c)/o):x}return{length:o,max:c,min:i,constrain:h,reachedAny:d,reachedMax:f,reachedMin:u,removeOffset:p}}function Ap(i,c,o){const{constrain:u}=ka(0,i),f=i+1;let d=h(c);function h(z){return o?$e((f+z)%f):u(z)}function p(){return d}function g(z){return d=h(z),y}function x(z){return v().set(p()+z)}function v(){return Ap(i,p(),o)}const y={get:p,set:g,add:x,clone:v};return y}function kv(i,c,o,u,f,d,h,p,g,x,v,y,z,w,N,A,D,U,X){const{cross:Z,direction:P}=i,$=["INPUT","SELECT","TEXTAREA"],K={passive:!1},G=Us(),ee=Us(),se=ka(50,225).constrain(w.measure(20)),Q={mouse:300,touch:400},ae={mouse:500,touch:600},ye=N?43:25;let Ge=!1,Qe=0,ce=0,k=!1,Y=!1,te=!1,he=!1;function je(I){if(!X)return;function Te(tt){(pc(X)||X(I,tt))&&fe(tt)}const ke=c;G.add(ke,"dragstart",tt=>tt.preventDefault(),K).add(ke,"touchmove",()=>{},K).add(ke,"touchend",()=>{}).add(ke,"touchstart",Te).add(ke,"mousedown",Te).add(ke,"touchcancel",ze).add(ke,"contextmenu",ze).add(ke,"click",Ee,!0)}function S(){G.clear(),ee.clear()}function L(){const I=he?o:c;ee.add(I,"touchmove",oe,K).add(I,"touchend",ze).add(I,"mousemove",oe,K).add(I,"mouseup",ze)}function V(I){const Te=I.nodeName||"";return $.includes(Te)}function J(){return(N?ae:Q)[he?"mouse":"touch"]}function re(I,Te){const ke=y.add(du(I)*-1),tt=v.byDistance(I,!N).distance;return N||$e(I)<se?tt:D&&Te?tt*.5:v.byIndex(ke.get(),0).distance}function fe(I){const Te=Ko(I,u);he=Te,te=N&&Te&&!I.buttons&&Ge,Ge=As(f.get(),h.get())>=2,!(Te&&I.button!==0)&&(V(I.target)||(k=!0,d.pointerDown(I),x.useFriction(0).useDuration(0),f.set(h),L(),Qe=d.readPoint(I),ce=d.readPoint(I,Z),z.emit("pointerDown")))}function oe(I){if(!Ko(I,u)&&I.touches.length>=2)return ze(I);const ke=d.readPoint(I),tt=d.readPoint(I,Z),yt=As(ke,Qe),Jt=As(tt,ce);if(!Y&&!he&&(!I.cancelable||(Y=yt>Jt,!Y)))return ze(I);const ll=d.pointerMove(I);yt>A&&(te=!0),x.useFriction(.3).useDuration(.75),p.start(),f.add(P(ll)),I.preventDefault()}function ze(I){const ke=v.byDistance(0,!1).index!==y.get(),tt=d.pointerUp(I)*J(),yt=re(P(tt),ke),Jt=Tv(tt,yt),ll=ye-10*Jt,Ht=U+Jt/50;Y=!1,k=!1,ee.clear(),x.useDuration(ll).useFriction(Ht),g.distance(yt,!N),he=!1,z.emit("pointerUp")}function Ee(I){te&&(I.stopPropagation(),I.preventDefault(),te=!1)}function at(){return k}return{init:je,destroy:S,pointerDown:at}}function Ov(i,c){let u,f;function d(y){return y.timeStamp}function h(y,z){const N=`client${(z||i.scroll)==="x"?"X":"Y"}`;return(Ko(y,c)?y:y.touches[0])[N]}function p(y){return u=y,f=y,h(y)}function g(y){const z=h(y)-h(f),w=d(y)-d(u)>170;return f=y,w&&(u=y),z}function x(y){if(!u||!f)return 0;const z=h(f)-h(u),w=d(y)-d(u),N=d(y)-d(f)>170,A=z/w;return w&&!N&&$e(A)>.1?A:0}return{pointerDown:p,pointerMove:g,pointerUp:x,readPoint:h}}function Dv(){function i(o){const{offsetTop:u,offsetLeft:f,offsetWidth:d,offsetHeight:h}=o;return{top:u,right:f+d,bottom:u+h,left:f,width:d,height:h}}return{measure:i}}function Rv(i){function c(u){return i*(u/100)}return{measure:c}}function Uv(i,c,o,u,f,d,h){const p=[i].concat(u);let g,x,v=[],y=!1;function z(D){return f.measureSize(h.measure(D))}function w(D){if(!d)return;x=z(i),v=u.map(z);function U(X){for(const Z of X){if(y)return;const P=Z.target===i,$=u.indexOf(Z.target),K=P?x:v[$],G=z(P?i:u[$]);if($e(G-K)>=.5){D.reInit(),c.emit("resize");break}}}g=new ResizeObserver(X=>{(pc(d)||d(D,X))&&U(X)}),o.requestAnimationFrame(()=>{p.forEach(X=>g.observe(X))})}function N(){y=!0,g&&g.disconnect()}return{init:w,destroy:N}}function Hv(i,c,o,u,f,d){let h=0,p=0,g=f,x=d,v=i.get(),y=0;function z(){const K=u.get()-i.get(),G=!g;let ee=0;return G?(h=0,o.set(u),i.set(u),ee=K):(o.set(i),h+=K/g,h*=x,v+=h,i.add(h),ee=v-y),p=du(ee),y=v,$}function w(){const K=u.get()-c.get();return $e(K)<.001}function N(){return g}function A(){return p}function D(){return h}function U(){return Z(f)}function X(){return P(d)}function Z(K){return g=K,$}function P(K){return x=K,$}const $={direction:A,duration:N,velocity:D,seek:z,settled:w,useBaseFriction:X,useBaseDuration:U,useFriction:P,useDuration:Z};return $}function Lv(i,c,o,u,f){const d=f.measure(10),h=f.measure(50),p=ka(.1,.99);let g=!1;function x(){return!(g||!i.reachedAny(o.get())||!i.reachedAny(c.get()))}function v(w){if(!x())return;const N=i.reachedMin(c.get())?"min":"max",A=$e(i[N]-c.get()),D=o.get()-c.get(),U=p.constrain(A/h);o.subtract(D*U),!w&&$e(D)<d&&(o.set(i.constrain(o.get())),u.useDuration(25).useBaseFriction())}function y(w){g=!w}return{shouldConstrain:x,constrain:v,toggleActive:y}}function Bv(i,c,o,u,f){const d=ka(-c+i,0),h=y(),p=v(),g=z();function x(N,A){return As(N,A)<=1}function v(){const N=h[0],A=el(h),D=h.lastIndexOf(N),U=h.indexOf(A)+1;return ka(D,U)}function y(){return o.map((N,A)=>{const{min:D,max:U}=d,X=d.constrain(N),Z=!A,P=mu(o,A);return Z?U:P||x(D,X)?D:x(U,X)?U:X}).map(N=>parseFloat(N.toFixed(3)))}function z(){if(c<=i+f)return[d.max];if(u==="keepSnaps")return h;const{min:N,max:A}=p;return h.slice(N,A)}return{snapsContained:g,scrollContainLimit:p}}function qv(i,c,o){const u=c[0],f=o?u-i:el(c);return{limit:ka(f,u)}}function Yv(i,c,o,u){const d=c.min+.1,h=c.max+.1,{reachedMin:p,reachedMax:g}=ka(d,h);function x(z){return z===1?g(o.get()):z===-1?p(o.get()):!1}function v(z){if(!x(z))return;const w=i*(z*-1);u.forEach(N=>N.add(w))}return{loop:v}}function Gv(i){const{max:c,length:o}=i;function u(d){const h=d-c;return o?h/-o:0}return{get:u}}function $v(i,c,o,u,f){const{startEdge:d,endEdge:h}=i,{groupSlides:p}=f,g=y().map(c.measure),x=z(),v=w();function y(){return p(u).map(A=>el(A)[h]-A[0][d]).map($e)}function z(){return u.map(A=>o[d]-A[d]).map(A=>-$e(A))}function w(){return p(x).map(A=>A[0]).map((A,D)=>A+g[D])}return{snaps:x,snapsAligned:v}}function Qv(i,c,o,u,f,d){const{groupSlides:h}=f,{min:p,max:g}=u,x=v();function v(){const z=h(d),w=!i||c==="keepSnaps";return o.length===1?[d]:w?z:z.slice(p,g).map((N,A,D)=>{const U=!A,X=mu(D,A);if(U){const Z=el(D[0])+1;return Yh(Z)}if(X){const Z=Ys(d)-el(D)[0]+1;return Yh(Z,el(D)[0])}return N})}return{slideRegistry:x}}function Xv(i,c,o,u,f){const{reachedAny:d,removeOffset:h,constrain:p}=u;function g(N){return N.concat().sort((A,D)=>$e(A)-$e(D))[0]}function x(N){const A=i?h(N):p(N),D=c.map((X,Z)=>({diff:v(X-A,0),index:Z})).sort((X,Z)=>$e(X.diff)-$e(Z.diff)),{index:U}=D[0];return{index:U,distance:A}}function v(N,A){const D=[N,N+o,N-o];if(!i)return N;if(!A)return g(D);const U=D.filter(X=>du(X)===A);return U.length?g(U):el(D)-o}function y(N,A){const D=c[N]-f.get(),U=v(D,A);return{index:N,distance:U}}function z(N,A){const D=f.get()+N,{index:U,distance:X}=x(D),Z=!i&&d(D);if(!A||Z)return{index:U,distance:N};const P=c[U]-X,$=N+v(P,0);return{index:U,distance:$}}return{byDistance:z,byIndex:y,shortcut:v}}function Vv(i,c,o,u,f,d,h){function p(y){const z=y.distance,w=y.index!==c.get();d.add(z),z&&(u.duration()?i.start():(i.update(),i.render(1),i.update())),w&&(o.set(c.get()),c.set(y.index),h.emit("select"))}function g(y,z){const w=f.byDistance(y,z);p(w)}function x(y,z){const w=c.clone().set(y),N=f.byIndex(w.get(),z);p(N)}return{distance:g,index:x}}function Zv(i,c,o,u,f,d,h,p){const g={passive:!0,capture:!0};let x=0;function v(w){if(!p)return;function N(A){if(new Date().getTime()-x>10)return;h.emit("slideFocusStart"),i.scrollLeft=0;const X=o.findIndex(Z=>Z.includes(A));fu(X)&&(f.useDuration(0),u.index(X,0),h.emit("slideFocus"))}d.add(document,"keydown",y,!1),c.forEach((A,D)=>{d.add(A,"focus",U=>{(pc(p)||p(w,U))&&N(D)},g)})}function y(w){w.code==="Tab"&&(x=new Date().getTime())}return{init:v}}function ws(i){let c=i;function o(){return c}function u(g){c=h(g)}function f(g){c+=h(g)}function d(g){c-=h(g)}function h(g){return fu(g)?g:g.get()}return{get:o,set:u,add:f,subtract:d}}function _p(i,c){const o=i.scroll==="x"?h:p,u=c.style;let f=null,d=!1;function h(z){return`translate3d(${z}px,0px,0px)`}function p(z){return`translate3d(0px,${z}px,0px)`}function g(z){if(d)return;const w=Av(i.direction(z));w!==f&&(u.transform=o(w),f=w)}function x(z){d=!z}function v(){d||(u.transform="",c.getAttribute("style")||c.removeAttribute("style"))}return{clear:v,to:g,toggleActive:x}}function Kv(i,c,o,u,f,d,h,p,g){const v=Ds(f),y=Ds(f).reverse(),z=U().concat(X());function w(G,ee){return G.reduce((se,Q)=>se-f[Q],ee)}function N(G,ee){return G.reduce((se,Q)=>w(se,ee)>0?se.concat([Q]):se,[])}function A(G){return d.map((ee,se)=>({start:ee-u[se]+.5+G,end:ee+c-.5+G}))}function D(G,ee,se){const Q=A(ee);return G.map(ae=>{const ye=se?0:-o,Ge=se?o:0,Qe=se?"end":"start",ce=Q[ae][Qe];return{index:ae,loopPoint:ce,slideLocation:ws(-1),translate:_p(i,g[ae]),target:()=>p.get()>ce?ye:Ge}})}function U(){const G=h[0],ee=N(y,G);return D(ee,o,!1)}function X(){const G=c-h[0]-1,ee=N(v,G);return D(ee,-o,!0)}function Z(){return z.every(({index:G})=>{const ee=v.filter(se=>se!==G);return w(ee,c)<=.1})}function P(){z.forEach(G=>{const{target:ee,translate:se,slideLocation:Q}=G,ae=ee();ae!==Q.get()&&(se.to(ae),Q.set(ae))})}function $(){z.forEach(G=>G.translate.clear())}return{canLoop:Z,clear:$,loop:P,loopPoints:z}}function Jv(i,c,o){let u,f=!1;function d(g){if(!o)return;function x(v){for(const y of v)if(y.type==="childList"){g.reInit(),c.emit("slidesChanged");break}}u=new MutationObserver(v=>{f||(pc(o)||o(g,v))&&x(v)}),u.observe(i,{childList:!0})}function h(){u&&u.disconnect(),f=!0}return{init:d,destroy:h}}function Wv(i,c,o,u){const f={};let d=null,h=null,p,g=!1;function x(){p=new IntersectionObserver(N=>{g||(N.forEach(A=>{const D=c.indexOf(A.target);f[D]=A}),d=null,h=null,o.emit("slidesInView"))},{root:i.parentElement,threshold:u}),c.forEach(N=>p.observe(N))}function v(){p&&p.disconnect(),g=!0}function y(N){return Rs(f).reduce((A,D)=>{const U=parseInt(D),{isIntersecting:X}=f[U];return(N&&X||!N&&!X)&&A.push(U),A},[])}function z(N=!0){if(N&&d)return d;if(!N&&h)return h;const A=y(N);return N&&(d=A),N||(h=A),A}return{init:x,destroy:v,get:z}}function Fv(i,c,o,u,f,d){const{measureSize:h,startEdge:p,endEdge:g}=i,x=o[0]&&f,v=N(),y=A(),z=o.map(h),w=D();function N(){if(!x)return 0;const X=o[0];return $e(c[p]-X[p])}function A(){if(!x)return 0;const X=d.getComputedStyle(el(u));return parseFloat(X.getPropertyValue(`margin-${g}`))}function D(){return o.map((X,Z,P)=>{const $=!Z,K=mu(P,Z);return $?z[Z]+v:K?z[Z]+y:P[Z+1][p]-X[p]}).map($e)}return{slideSizes:z,slideSizesWithGaps:w,startGap:v,endGap:y}}function Iv(i,c,o,u,f,d,h,p,g){const{startEdge:x,endEdge:v,direction:y}=i,z=fu(o);function w(U,X){return Ds(U).filter(Z=>Z%X===0).map(Z=>U.slice(Z,Z+X))}function N(U){return U.length?Ds(U).reduce((X,Z,P)=>{const $=el(X)||0,K=$===0,G=Z===Ys(U),ee=f[x]-d[$][x],se=f[x]-d[Z][v],Q=!u&&K?y(h):0,ae=!u&&G?y(p):0,ye=$e(se-ae-(ee+Q));return P&&ye>c+g&&X.push(Z),G&&X.push(U.length),X},[]).map((X,Z,P)=>{const $=Math.max(P[Z-1]||0);return U.slice($,X)}):[]}function A(U){return z?w(U,o):N(U)}return{groupSlides:A}}function Pv(i,c,o,u,f,d,h){const{align:p,axis:g,direction:x,startIndex:v,loop:y,duration:z,dragFree:w,dragThreshold:N,inViewThreshold:A,slidesToScroll:D,skipSnaps:U,containScroll:X,watchResize:Z,watchSlides:P,watchDrag:$,watchFocus:K}=d,G=2,ee=Dv(),se=ee.measure(c),Q=o.map(ee.measure),ae=Mv(g,x),ye=ae.measureSize(se),Ge=Rv(ye),Qe=_v(p,ye),ce=!y&&!!X,k=y||!!X,{slideSizes:Y,slideSizesWithGaps:te,startGap:he,endGap:je}=Fv(ae,se,Q,o,k,f),S=Iv(ae,ye,D,y,se,Q,he,je,G),{snaps:L,snapsAligned:V}=$v(ae,Qe,se,Q,S),J=-el(L)+el(te),{snapsContained:re,scrollContainLimit:fe}=Bv(ye,J,V,X,G),oe=ce?re:V,{limit:ze}=qv(J,oe,y),Ee=Ap(Ys(oe),v,y),at=Ee.clone(),Me=Ds(o),I=({dragHandler:pl,scrollBody:kn,scrollBounds:On,options:{loop:al}})=>{al||On.constrain(pl.pointerDown()),kn.seek()},Te=({scrollBody:pl,translate:kn,location:On,offsetLocation:al,previousLocation:pt,scrollLooper:nl,slideLooper:xt,dragHandler:xc,animation:gc,eventHandler:Xs,scrollBounds:Ra,options:{loop:da}},ma)=>{const sl=pl.settled(),Ua=!Ra.shouldConstrain(),Ul=da?sl:sl&&Ua,Vs=Ul&&!xc.pointerDown();Vs&&gc.stop();const Zs=On.get()*ma+pt.get()*(1-ma);al.set(Zs),da&&(nl.loop(pl.direction()),xt.loop()),kn.to(al.get()),Vs&&Xs.emit("settle"),Ul||Xs.emit("scroll")},ke=Cv(u,f,()=>I(Mn),pl=>Te(Mn,pl)),tt=.68,yt=oe[Ee.get()],Jt=ws(yt),ll=ws(yt),Ht=ws(yt),Wt=ws(yt),hl=Hv(Jt,Ht,ll,Wt,z,tt),An=Xv(y,oe,J,ze,Wt),_n=Vv(ke,Ee,at,hl,An,Wt,h),dt=Gv(ze),$s=Us(),Qs=Wv(c,o,h,A),{slideRegistry:Cn}=Qv(ce,X,oe,fe,S,Me),Da=Zv(i,o,Cn,_n,hl,$s,h,K),Mn={ownerDocument:u,ownerWindow:f,eventHandler:h,containerRect:se,slideRects:Q,animation:ke,axis:ae,dragHandler:kv(ae,i,u,f,Wt,Ov(ae,f),Jt,ke,_n,hl,An,Ee,h,Ge,w,N,U,tt,$),eventStore:$s,percentOfView:Ge,index:Ee,indexPrevious:at,limit:ze,location:Jt,offsetLocation:Ht,previousLocation:ll,options:d,resizeHandler:Uv(c,h,f,o,ae,Z,ee),scrollBody:hl,scrollBounds:Lv(ze,Ht,Wt,hl,Ge),scrollLooper:Yv(J,ze,Ht,[Jt,Ht,ll,Wt]),scrollProgress:dt,scrollSnapList:oe.map(dt.get),scrollSnaps:oe,scrollTarget:An,scrollTo:_n,slideLooper:Kv(ae,ye,J,Y,te,L,oe,Ht,o),slideFocus:Da,slidesHandler:Jv(c,h,P),slidesInView:Qs,slideIndexes:Me,slideRegistry:Cn,slidesToScroll:S,target:Wt,translate:_p(ae,c)};return Mn}function ey(){let i={},c;function o(x){c=x}function u(x){return i[x]||[]}function f(x){return u(x).forEach(v=>v(c,x)),g}function d(x,v){return i[x]=u(x).concat([v]),g}function h(x,v){return i[x]=u(x).filter(y=>y!==v),g}function p(){i={}}const g={init:o,emit:f,off:h,on:d,clear:p};return g}const ty={align:"center",axis:"x",container:null,slides:null,containScroll:"trimSnaps",direction:"ltr",slidesToScroll:1,inViewThreshold:0,breakpoints:{},dragFree:!1,dragThreshold:10,loop:!1,skipSnaps:!1,duration:25,startIndex:0,active:!0,watchDrag:!0,watchResize:!0,watchSlides:!0,watchFocus:!0};function ly(i){function c(d,h){return Tp(d,h||{})}function o(d){const h=d.breakpoints||{},p=Rs(h).filter(g=>i.matchMedia(g).matches).map(g=>h[g]).reduce((g,x)=>c(g,x),{});return c(d,p)}function u(d){return d.map(h=>Rs(h.breakpoints||{})).reduce((h,p)=>h.concat(p),[]).map(i.matchMedia)}return{mergeOptions:c,optionsAtMedia:o,optionsMediaQueries:u}}function ay(i){let c=[];function o(d,h){return c=h.filter(({options:p})=>i.optionsAtMedia(p).active!==!1),c.forEach(p=>p.init(d,i)),h.reduce((p,g)=>Object.assign(p,{[g.name]:g}),{})}function u(){c=c.filter(d=>d.destroy())}return{init:o,destroy:u}}function uc(i,c,o){const u=i.ownerDocument,f=u.defaultView,d=ly(f),h=ay(d),p=Us(),g=ey(),{mergeOptions:x,optionsAtMedia:v,optionsMediaQueries:y}=d,{on:z,off:w,emit:N}=g,A=ae;let D=!1,U,X=x(ty,uc.globalOptions),Z=x(X),P=[],$,K,G;function ee(){const{container:Me,slides:I}=Z;K=(Zo(Me)?i.querySelector(Me):Me)||i.children[0];const ke=Zo(I)?K.querySelectorAll(I):I;G=[].slice.call(ke||K.children)}function se(Me){const I=Pv(i,K,G,u,f,Me,g);if(Me.loop&&!I.slideLooper.canLoop()){const Te=Object.assign({},Me,{loop:!1});return se(Te)}return I}function Q(Me,I){D||(X=x(X,Me),Z=v(X),P=I||P,ee(),U=se(Z),y([X,...P.map(({options:Te})=>Te)]).forEach(Te=>p.add(Te,"change",ae)),Z.active&&(U.translate.to(U.location.get()),U.animation.init(),U.slidesInView.init(),U.slideFocus.init(at),U.eventHandler.init(at),U.resizeHandler.init(at),U.slidesHandler.init(at),U.options.loop&&U.slideLooper.loop(),K.offsetParent&&G.length&&U.dragHandler.init(at),$=h.init(at,P)))}function ae(Me,I){const Te=S();ye(),Q(x({startIndex:Te},Me),I),g.emit("reInit")}function ye(){U.dragHandler.destroy(),U.eventStore.clear(),U.translate.clear(),U.slideLooper.clear(),U.resizeHandler.destroy(),U.slidesHandler.destroy(),U.slidesInView.destroy(),U.animation.destroy(),h.destroy(),p.clear()}function Ge(){D||(D=!0,p.clear(),ye(),g.emit("destroy"),g.clear())}function Qe(Me,I,Te){!Z.active||D||(U.scrollBody.useBaseFriction().useDuration(I===!0?0:Z.duration),U.scrollTo.index(Me,Te||0))}function ce(Me){const I=U.index.add(1).get();Qe(I,Me,-1)}function k(Me){const I=U.index.add(-1).get();Qe(I,Me,1)}function Y(){return U.index.add(1).get()!==S()}function te(){return U.index.add(-1).get()!==S()}function he(){return U.scrollSnapList}function je(){return U.scrollProgress.get(U.offsetLocation.get())}function S(){return U.index.get()}function L(){return U.indexPrevious.get()}function V(){return U.slidesInView.get()}function J(){return U.slidesInView.get(!1)}function re(){return $}function fe(){return U}function oe(){return i}function ze(){return K}function Ee(){return G}const at={canScrollNext:Y,canScrollPrev:te,containerNode:ze,internalEngine:fe,destroy:Ge,off:w,on:z,emit:N,plugins:re,previousScrollSnap:L,reInit:A,rootNode:oe,scrollNext:ce,scrollPrev:k,scrollProgress:je,scrollSnapList:he,scrollTo:Qe,selectedScrollSnap:S,slideNodes:Ee,slidesInView:V,slidesNotInView:J};return Q(c,o),setTimeout(()=>g.emit("init"),0),at}uc.globalOptions=void 0;function hu(i={},c=[]){const o=q.useRef(i),u=q.useRef(c),[f,d]=q.useState(),[h,p]=q.useState(),g=q.useCallback(()=>{f&&f.reInit(o.current,u.current)},[f]);return q.useEffect(()=>{uu(o.current,i)||(o.current=i,g())},[i,g]),q.useEffect(()=>{wv(u.current,c)||(u.current=c,g())},[c,g]),q.useEffect(()=>{if(Ev()&&h){uc.globalOptions=hu.globalOptions;const x=uc(h,o.current,u.current);return d(x),()=>x.destroy()}else d(void 0)},[h,d]),[p,f]}hu.globalOptions=void 0;const pu=4500;function xu({box:i,interval:c,advance:o}){const u=q.useRef(o);u.current=o,q.useEffect(()=>{const f=i.current;if(!f||c<=0||window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;let d=0,h=!0;const p=()=>{d+=1},g=()=>{d=Math.max(0,d-1)},x=window.setInterval(()=>{d>0||document.hidden||!h||u.current()||window.clearInterval(x)},c),v=new IntersectionObserver(([A])=>{h=A.isIntersecting},{threshold:.25});v.observe(f);const y=window.matchMedia("(hover: hover) and (pointer: fine)").matches;y&&(f.addEventListener("pointerenter",p),f.addEventListener("pointerleave",g));let z=!1;const w=A=>{const D=A.target;!(D instanceof Element)||!D.matches(":focus-visible")||z||(z=!0,p())},N=()=>{z&&(z=!1,g())};return f.addEventListener("focusin",w),f.addEventListener("focusout",N),f.addEventListener("pointerdown",p,!0),window.addEventListener("pointerup",g,!0),window.addEventListener("pointercancel",g,!0),()=>{window.clearInterval(x),v.disconnect(),y&&(f.removeEventListener("pointerenter",p),f.removeEventListener("pointerleave",g)),f.removeEventListener("focusin",w),f.removeEventListener("focusout",N),f.removeEventListener("pointerdown",p,!0),window.removeEventListener("pointerup",g,!0),window.removeEventListener("pointercancel",g,!0)}},[i,c])}function Cp(i,c=.85){if(!i)return!1;const o=i.scrollWidth-i.clientWidth;if(o<=8||i.scrollLeft>=o-8)return!1;const u=window.matchMedia("(prefers-reduced-motion: reduce)").matches;return i.scrollBy({left:i.clientWidth*c,behavior:u?"auto":"smooth"}),!0}function gu({label:i,children:c,align:o="start",loop:u=!1,dots:f=!0,arrows:d="header",gap:h=1,autoplay:p=pu,onSelect:g,onReady:x,className:v}){const y=p===!1?0:p,[z,w]=hu({align:o,loop:u,containScroll:"trimSnaps",watchDrag:!0}),[N,A]=q.useState([]),[D,U]=q.useState(0),[X,Z]=q.useState(!1),[P,$]=q.useState(!1),K=q.useRef(null),G=q.useId();q.useEffect(()=>{var Y;if(!w)return;const ce=()=>{U(w.selectedScrollSnap()),Z(w.canScrollPrev()),$(w.canScrollNext())},k=()=>{A(w.scrollSnapList()),ce()};return k(),w.on("select",ce),w.on("reInit",k),(Y=K.current)==null||Y.setAttribute("data-slider","on"),()=>{w.off("select",ce),w.off("reInit",k)}},[w]),q.useEffect(()=>{if(!w)return;const ce=K.current,k=()=>ce==null?void 0:ce.setAttribute("data-dragging","true"),Y=()=>ce==null?void 0:ce.removeAttribute("data-dragging");return w.on("pointerDown",k),w.on("pointerUp",Y),()=>{w.off("pointerDown",k),w.off("pointerUp",Y)}},[w]),xu({box:K,interval:y,advance:()=>!w||w.scrollSnapList().length<=1?!0:w.canScrollNext()?(w.scrollNext(),!0):!1});const ee=q.useCallback(ce=>w==null?void 0:w.scrollTo(ce),[w]),se=q.useRef(g);se.current=g;const Q=q.useRef(x);Q.current=x,q.useEffect(()=>{var k;if(!w)return;const ce=()=>{var Y;return(Y=se.current)==null?void 0:Y.call(se,w.selectedScrollSnap())};return ce(),w.on("select",ce),w.on("reInit",ce),(k=Q.current)==null||k.call(Q,{scrollTo:Y=>w.scrollTo(Y)}),()=>{w.off("select",ce),w.off("reInit",ce)}},[w]);const ae=q.useCallback(()=>w==null?void 0:w.scrollPrev(),[w]),ye=q.useCallback(()=>w==null?void 0:w.scrollNext(),[w]),Ge=N.length>1,Qe=f&&Ge&&N.length<=12;return n.jsxs("div",{className:v,children:[d==="header"&&Ge&&n.jsxs("div",{className:"mb-3 flex justify-end gap-2",children:[n.jsx(Gh,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx(Gh,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]}),n.jsxs("div",{className:"relative",children:[n.jsx("div",{ref:ce=>{K.current=ce,z(ce)},className:"slider-viewport",role:"group","aria-roledescription":"캐러셀","aria-label":i,tabIndex:0,onKeyDown:ce=>{ce.key==="ArrowLeft"&&(ce.preventDefault(),ae()),ce.key==="ArrowRight"&&(ce.preventDefault(),ye())},children:n.jsx("div",{className:"slider-track",style:{gap:`${h}rem`},id:G,children:c})}),d==="overlay"&&Ge&&n.jsxs(n.Fragment,{children:[n.jsx($h,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx($h,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]})]}),Ge&&n.jsxs("div",{className:"mt-4 flex items-center justify-center gap-3",children:[Qe&&n.jsx("ul",{className:"flex items-center gap-1.5",children:N.map((ce,k)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>ee(k),"aria-label":`${k+1}번째로`,"aria-current":k===D,"aria-controls":G,className:`h-1.5 rounded-full transition-all ${k===D?"w-6 bg-current opacity-70":"w-1.5 bg-current opacity-20 hover:opacity-45"}`})},k))}),n.jsxs("span",{className:"text-current/55 text-[length:var(--fs-xs)] tabular-nums",children:[D+1," / ",N.length]})]})]})}function Mp({basis:i,children:c}){return n.jsx("div",{className:`min-w-0 shrink-0 grow-0 ${i}`,"aria-roledescription":"슬라이드",children:c})}function Gh({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:"tap border-line flex items-center justify-center rounded-full border transition-opacity hover:opacity-70 disabled:opacity-25",children:n.jsx(f,{className:"size-5"})})}function $h({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:`tap absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:opacity-0 sm:flex ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(f,{className:"size-5"})})}function bu({children:i,className:c="",as:o="div"}){return n.jsx(o,{className:`panel ${c}`,children:i})}function vu({children:i}){return n.jsx("span",{className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]",children:i})}function kp({label:i,value:c,note:o}){return n.jsxs("div",{className:"divide-line flex flex-col gap-1 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] font-medium sm:w-40",children:i}),n.jsxs("dd",{className:"flex-1 text-[length:var(--fs-sm)] font-semibold sm:text-right",children:[c,o&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:o})]})]})}function Qh({href:i,children:c,variant:o="solid",external:u,icon:f}){const d=o==="solid";return n.jsxs("a",{href:i,...u?{target:"_blank",rel:"noopener noreferrer"}:{},className:`tap inline-flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-semibold transition-opacity hover:opacity-85 ${d?"":"border-line tpl-border border bg-current/5"}`,style:d?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"}:void 0,children:[f,n.jsx("span",{children:c}),u&&n.jsx(Sn,{className:"size-4 shrink-0"})]})}function ny(){const i=le(),{narrative:c,place:o}=i,u=H0(i,"intro"),f=u.length>0?u:c.about;if(f.length===0)return null;const d=lu(i).find(p=>!p.isPrimary),h=c.heroSubline??`${o.name} 소개`;return n.jsx(vt,{id:"about",bare:!0,title:h,children:n.jsxs("div",{className:"grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14",children:[d&&n.jsxs("figure",{className:"lg:col-span-7",children:[n.jsx("div",{className:"tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2",children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover"})}),d.caption&&n.jsx("figcaption",{className:"text-muted mt-2 text-[length:var(--fs-xs)]",children:d.caption})]}),n.jsxs("div",{className:d?"lg:col-span-5":"lg:col-span-12",children:[n.jsxs("h2",{id:"about-heading",className:"h2 mb-6 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:h})]}),n.jsx("div",{className:"measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]",children:f.map((p,g)=>n.jsx("p",{children:p},g))})]})]})})}const Xh=new Set(["체크인 시간","체크아웃 시간","취소·환불 규정","취사 가능","반려동물 동반","흡연 가능","인원 추가 요금"]);function sy(){const i=le(),c=dc(i),o=i.facts.filter(d=>d.scope==="place"&&!c.some(h=>h.label===d.label)).length;if(c.length===0)return null;const u=c.filter(d=>Xh.has(d.label)),f=c.filter(d=>!Xh.has(d.label));return n.jsx(vt,{id:"info",tone:"alt",title:"이용안내 및 예약",lead:"방문 전 확인이 필요한 운영 규정과 시설 안내입니다.",footnote:n.jsxs("span",{className:"flex flex-col gap-1 sm:flex-row sm:justify-between",children:[n.jsx("span",{children:o>0?`확인 중인 항목 ${o}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`:"모든 항목이 사업자 확인을 거쳤습니다."}),n.jsxs("span",{className:"font-medium",children:[nu(i.site.updatedAt)," 기준"]})]}),children:n.jsxs("div",{className:"space-y-8",children:[u.length>0&&n.jsx(Vh,{title:"예약 전 확인",rows:u,emphasis:!0}),f.length>0&&n.jsx(Vh,{title:"시설 · 편의",rows:f}),n.jsx(iy,{})]})})}function Vh({title:i,rows:c,emphasis:o}){return n.jsxs("section",{children:[n.jsxs("h3",{className:"border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[o&&n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0",style:{backgroundColor:"var(--color-accent)"}}),n.jsx("span",{children:i})]}),n.jsx("dl",{className:"divide-line divide-y",children:c.map(u=>n.jsxs("div",{className:"flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6",children:[n.jsx("dt",{className:"text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0",children:u.label}),n.jsxs("dd",{className:"measure text-[length:var(--fs-sm)] font-semibold",children:[u.value,u.note&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:u.note})]})]},u.label))})]})}function iy(){const i=le(),c=Ze(i),o=i.place.phone;return!o&&c.length===0?null:n.jsxs("div",{className:"border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("p",{className:"text-[length:var(--fs-sm)] font-semibold",children:[i.place.name," 예약은 아래로 받습니다."]}),n.jsxs("div",{className:"flex flex-wrap gap-2",children:[o&&n.jsxs("a",{href:`tel:${o}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:o})]}),c.map(u=>n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)},u.url))]})]})}function cy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:n.jsx("ul",{className:"mt-2 flex flex-col gap-14 lg:gap-20",children:o.map((f,d)=>n.jsxs("li",{className:"grid items-center gap-7 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-2":""}`,children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 rounded-lg overflow-hidden",children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),f.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:f.images.slice(1,4).map(h=>n.jsx("li",{className:"relative aspect-16/10 rounded-md overflow-hidden",children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]}),n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-1":""}`,children:[n.jsx("h3",{className:"h2",children:f.name}),f.intro&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed opacity-80",children:f.intro}),f.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:h.label}),n.jsx("span",{className:"font-medium",children:h.value})]})},h.label))}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,f.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),f.rows.length>0&&n.jsxs("details",{className:"group border-line mt-4 border-t pt-2",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[f.name," 구성 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right font-semibold",children:h.value})]},h.label))})]})]})]},f.unitId))})})}function ry(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0],[f,d]=q.useState(0);return o.length===0?null:n.jsxs(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:[n.jsx("div",{className:"border-line mb-6 flex gap-1 border-b",role:"tablist","aria-label":c.label,children:o.map((h,p)=>n.jsx("button",{type:"button",role:"tab","aria-selected":p===f,"aria-controls":`unit-panel-${h.slug}`,onClick:()=>d(p),className:`tap -mb-px border-b-2 px-5 text-[length:var(--fs-sm)] font-bold transition-colors ${p===f?"opacity-100":"border-transparent opacity-50 hover:opacity-80"}`,style:p===f?{borderColor:"var(--color-brand)"}:void 0,children:h.name},h.unitId))}),o.map((h,p)=>n.jsxs("div",{id:`unit-panel-${h.slug}`,role:"tabpanel",hidden:p!==f,className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"lg:col-span-7",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden rounded-lg",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),h.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:h.images.slice(1,4).map(g=>n.jsx("li",{className:"relative aspect-16/10 overflow-hidden rounded-md",children:n.jsx("img",{src:g.url,alt:g.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},g.mediaId))})]}),n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h3",{className:"h3",children:h.name}),h.intro&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:h.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),h.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-4 divide-y border-t",children:h.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))}),n.jsxs("div",{className:"mt-5 flex gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,h.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]})]})]},h.unitId))]})}function oy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];if(o.length===0)return null;const f=o.length===1?"grid-cols-1 max-w-2xl mx-auto":o.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",d=Kt(i,"info")?[]:dc(i);return n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"calc(var(--section-space) * 1.4)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(iu,{id:"units",title:Ye(i,c.path,`${c.label} 안내`)}),n.jsx("ul",{className:`grid gap-x-8 gap-y-14 lg:gap-x-12 ${f}`,children:o.map(h=>n.jsxs("li",{className:"flex min-w-0 flex-col",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-4/3 w-full overflow-hidden",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})}),n.jsx("h3",{className:"serif mt-6",style:{fontSize:"var(--fs-h3)",fontWeight:300,letterSpacing:"0.04em"},children:h.name}),h.intro&&n.jsx("p",{className:"text-muted mt-3 line-clamp-2 text-[length:var(--fs-sm)] font-light leading-loose",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"text-muted mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)] font-light",children:h.chips.map(p=>n.jsxs("li",{children:[p.label," ",p.value]},p.label))}),(h.rows.length>0||h.images.length>1)&&n.jsxs("details",{className:"border-line mt-5 border-t",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center text-[length:var(--fs-sm)] font-light opacity-70 marker:content-none [&::-webkit-details-marker]:hidden",children:"자세히 보기"}),n.jsxs("div",{className:"space-y-6 pb-2",children:[h.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:h.rows.map(p=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:p.label}),n.jsx("dd",{className:"text-right",children:p.value})]},p.label))}),h.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-3",children:h.images.slice(1).map(p=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})},p.mediaId))})]})]})]},h.unitId))}),d.length>0&&n.jsx("dl",{className:"divide-line border-line mx-auto mt-16 max-w-2xl divide-y border-t",children:d.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-3 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right",children:h.value})]},h.label))}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-14 flex flex-wrap items-center justify-center gap-4",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-opacity hover:opacity-70",children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tabular-nums tracking-[0.08em] transition-opacity hover:opacity-70",children:i.place.phone})]})]})})}function uy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsxs("section",{id:"units","data-oasi":!0,"aria-labelledby":"units-heading",className:"pt-[var(--oasi-gap)]",children:[n.jsx(cu,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-20 lg:space-y-28",children:o.map(f=>n.jsxs("li",{children:[f.images[0]&&n.jsxs("figure",{className:"relative mx-auto w-full max-w-[34rem]",children:[n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"block h-[clamp(15rem,24vw,21rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:f.name})]}),n.jsx("h3",{className:"mt-8 text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:f.name}),f.chips.length>0&&n.jsx("p",{className:"text-muted mt-2 text-[length:var(--fs-xs)] leading-[1.9]",children:f.chips.map(d=>`${d.label} ${d.value}`).join(" · ")}),f.intro&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-xs)] leading-[1.9]",children:f.intro}),f.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-6 divide-y border-t text-[length:var(--fs-xs)]",children:f.rows.map(d=>n.jsxs("div",{className:"flex items-baseline justify-between gap-6 py-2.5",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right",children:d.value})]},d.label))}),f.images.length>1&&n.jsx("ul",{className:"mx-auto mt-6 grid w-full max-w-[34rem] grid-cols-2 gap-2",children:f.images.slice(1).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",className:"block aspect-[4/3] w-full object-cover"})},d.mediaId))}),(u||i.place.phone)&&n.jsxs("p",{className:"mt-6 flex flex-wrap items-center gap-x-8 text-[length:var(--fs-sm)]",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap text-muted inline-flex items-center underline underline-offset-[6px]",children:i.place.phone})]})]},f.unitId))})]})}function fy(){const i=le(),c=ft(i),o=fl(i);return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"w-full",style:{paddingBlock:"var(--section-space)",backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ru,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-16 sm:space-y-24",children:o.map(u=>n.jsxs("li",{className:"grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"min-w-0 lg:col-span-7",children:[u.images[0]&&n.jsx("img",{src:u.images[0].url,alt:u.images[0].alt,loading:"lazy",decoding:"async",width:u.images[0].width,height:u.images[0].height,className:"block h-auto w-full"}),u.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-2 gap-2",children:u.images.slice(1).map(f=>n.jsx("li",{children:n.jsx("img",{src:f.url,alt:f.alt,loading:"lazy",decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})]}),n.jsxs("div",{className:"min-w-0 lg:col-span-5",children:[n.jsx("h3",{className:"serif min-w-0 break-keep font-extrabold",style:{fontSize:"var(--fs-h3)",letterSpacing:"0.02em",lineHeight:1.35},children:u.name}),u.chips.length>0&&n.jsx("dl",{className:"mt-4 space-y-1.5",children:u.chips.map(f=>n.jsxs("div",{className:"flex gap-3 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted w-20 shrink-0",children:f.label}),n.jsx("dd",{className:"min-w-0 font-medium",children:f.value})]},f.label))}),u.intro&&n.jsx("p",{className:"text-muted measure mt-5 text-[length:var(--fs-sm)]",style:{lineHeight:1.8},children:u.intro})]})]},u.unitId))})]})})}function dy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading","data-pastel":"own",className:"w-full",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ou,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:gap-8",children:o.map((f,d)=>n.jsxs("li",{className:"flex flex-col overflow-hidden p-4 sm:p-5",style:{backgroundColor:d%2===0?"var(--pastel-1)":"var(--pastel-2)",borderRadius:"calc(var(--tpl-radius, 0.75rem) * 1.35)"},children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",style:{borderRadius:"var(--tpl-radius, 0.75rem)"},children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 px-1 pt-4",children:[n.jsx("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:n.jsx("h3",{className:"h3",style:{fontWeight:800},children:f.name})}),f.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsxs("li",{className:"inline-flex items-center gap-1 rounded-full px-3 py-1 text-[length:var(--fs-xs)]",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 70%, transparent)"},children:[n.jsx("span",{className:"opacity-60",children:h.label}),n.jsx("span",{className:"font-bold",children:h.value})]},h.label))}),f.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:f.intro})]}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-4 flex gap-2 px-1",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-full text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(u)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap flex items-center justify-center gap-1.5 rounded-full px-5 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 75%, transparent)"},children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),(f.rows.length>0||f.images.length>1)&&n.jsxs("details",{className:"group mt-3 px-1",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsxs("span",{children:[f.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsxs("div",{className:"space-y-4 pb-1",children:[f.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"shrink-0 opacity-60",children:h.label}),n.jsx("dd",{className:"text-right font-bold",children:h.value})]},h.label))}),f.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-2",children:f.images.slice(1).map(h=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",style:{borderRadius:"calc(var(--tpl-radius, 0.75rem) * 0.7)"},children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]})]})]},f.unitId))})]})})}function my(){const i=le(),c=fl(i),o=ft(i),u=Ze(i)[0];return c.length===0?null:n.jsxs("section",{id:"units","aria-labelledby":"units-heading",className:"border-line w-full border-b",style:{backgroundColor:"var(--tpl-surface)",backgroundImage:Os,paddingBlock:"var(--section-space)"},children:[n.jsx("div",{className:"shell",children:n.jsx(au,{id:"units",title:Ye(i,o.path,`${o.label} ${c.length}개 안내`)})}),c.map((f,d)=>n.jsx(hy,{unit:f,no:String(d+1).padStart(2,"0"),dark:d%2===1,booking:u,phone:i.place.phone},f.unitId))]})}function hy({unit:i,no:c,dark:o,booking:u,phone:f}){return n.jsx("article",{className:"w-full",style:o?{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os}:void 0,children:n.jsxs("div",{className:"shell grid grid-cols-12 gap-y-6 py-10 xl:gap-x-10 xl:py-16",children:[n.jsxs("div",{className:"col-span-12 xl:col-span-5 xl:pr-8",children:[n.jsxs("div",{className:"relative",children:[n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute -top-1 left-0 select-none leading-none tabular-nums",style:{fontSize:"clamp(3rem, 8vw, 6rem)",fontWeight:800,opacity:.1},children:c}),n.jsx("h3",{className:"serif relative pt-7 xl:pt-12",style:{fontSize:"clamp(1.5rem, 3vw, 2.25rem)",fontWeight:800,lineHeight:1.1,letterSpacing:"-0.02em"},children:i.name})]}),i.intro&&n.jsx("p",{className:"measure mt-4 font-serif text-[length:var(--fs-body)] leading-relaxed opacity-85",children:i.intro}),i.rows.length>0&&n.jsx("dl",{className:"border-line mt-6 border-t",children:i.rows.map(d=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-6 border-b py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right font-semibold",children:d.value})]},d.label))}),(u||f)&&n.jsxs("div",{className:"mt-6 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(u)}),f&&n.jsx("a",{href:`tel:${f}`,className:"tap inline-flex items-center underline underline-offset-4",children:f})]})]}),n.jsxs("div",{className:"col-span-12 xl:col-span-7 xl:-ml-10",children:[i.images[0]&&n.jsx("figure",{className:"w-full xl:w-[64%]",children:n.jsx("img",{src:i.images[0].url,alt:i.images[0].alt,loading:"lazy",decoding:"async",width:i.images[0].width,height:i.images[0].height,className:"w-full object-cover",style:{aspectRatio:"4 / 5"}})}),i.images[1]&&n.jsx("figure",{className:"mt-3 w-full xl:-mt-24 xl:ml-auto xl:w-[50%]",children:n.jsx("img",{src:i.images[1].url,alt:i.images[1].alt,loading:"lazy",decoding:"async",width:i.images[1].width,height:i.images[1].height,className:"w-full object-cover",style:{aspectRatio:"4 / 3"}})})]}),i.images.length>2&&n.jsx("ul",{className:"col-span-12 grid grid-cols-3 gap-2 xl:grid-cols-6",children:i.images.slice(2).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"w-full object-cover",style:{aspectRatio:"3 / 2"}})},d.mediaId))})]})})}function Lo(){var h;const i=le(),c=ft(i),o=hc();if(o==="reservation")return n.jsx(oy,{});if(o==="oasi")return n.jsx(uy,{});if(o==="studio")return n.jsx(fy,{});if(o==="pastel")return n.jsx(dy,{});if(o==="editorial")return n.jsx(my,{});const u=(h=i.theme.sections.find(p=>p.id===c.path))==null?void 0:h.variantId;if(u==="rooms.bands")return n.jsx(cy,{});if(u==="rooms.tabs")return n.jsx(ry,{});const f=fl(i);if(f.length===0)return null;const d=f.length===1?"grid-cols-1":f.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 xl:grid-cols-3";return n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} ${f.length}개 안내`),children:n.jsx("ul",{className:`grid gap-5 lg:gap-7 ${d}`,children:f.map(p=>n.jsxs(bu,{as:"li",className:"flex flex-col overflow-hidden",children:[n.jsxs("div",{className:"flex flex-1 flex-col",children:[p.images.length>0&&n.jsx(py,{images:p.images,name:p.name}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 p-5",children:[n.jsx("h3",{className:"h3",children:p.name}),p.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:p.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),p.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:p.intro})]})]}),p.rows.length>0&&n.jsxs("details",{className:"group border-line border-t",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[p.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("div",{className:"space-y-5 px-5 pb-5",children:p.rows.length>0&&n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:p.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))})})]})]},p.unitId))})})}function py({images:i,name:c}){const o=q.useRef(null),[u,f]=q.useState(0),d=q.useCallback(()=>{const p=o.current;!p||p.clientWidth===0||f(Math.round(p.scrollLeft/p.clientWidth))},[]),h=q.useCallback(p=>{const g=o.current;if(!g)return;const x=window.matchMedia("(prefers-reduced-motion: reduce)").matches;g.scrollBy({left:p*g.clientWidth,behavior:x?"auto":"smooth"})},[]);return n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:o,onScroll:d,"aria-label":`${c} 사진`,className:"flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",children:i.map(p=>n.jsx("li",{className:"w-full shrink-0 snap-center",children:n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})})},p.mediaId))}),i.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Zh,{dir:"prev",show:u>0,onClick:()=>h(-1)}),n.jsx(Zh,{dir:"next",show:u<i.length-1,onClick:()=>h(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white",children:[Math.min(u+1,i.length)," / ",i.length]})]})]})}function Zh({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-4"})})}function xy(){const i=le(),c=Ze(i),o=q0(i),u=i.place.phone;return c.length===0&&o.length===0&&!u?null:n.jsx(vt,{id:"booking",tone:"alt",title:Ye(i,"booking","예약 안내"),lead:`${i.place.name} 예약은 아래 경로로 받습니다.`,children:n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[o.length>0&&n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5 lg:col-span-7",children:o.map(f=>n.jsx(kp,{label:f.label,value:f.value},f.label))}),n.jsx("div",{className:o.length>0?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:o.length>0?"flex flex-col gap-2.5 sm:flex-row sm:flex-wrap lg:flex-col":"flex flex-col items-start gap-2.5 sm:flex-row sm:flex-wrap",children:[u&&n.jsxs(Qh,{href:`tel:${u}`,icon:n.jsx(_t,{className:"size-4"}),children:["전화 예약 ",u]}),c.map(f=>n.jsx(Qh,{href:f.url,variant:"outline",external:!0,children:Sp(f)},f.url))]})})]})})}const Kh=["일","월","화","수","목","금","토"],Jh=2;function Wh(i){return`${i.getFullYear()}-${String(i.getMonth()+1).padStart(2,"0")}-${String(i.getDate()).padStart(2,"0")}`}function gy(i,c,o){const u=new Date(i,c,1),f=new Date(i,c+1,0).getDate(),d=Wh(o),h=Array.from({length:u.getDay()},()=>null);for(let p=1;p<=f;p+=1){const g=new Date(i,c,p),x=Wh(g);h.push({iso:x,day:p,weekday:g.getDay(),isWeekend:g.getDay()===0||g.getDay()===6,past:x<d})}return h}function by(i){const c=/(\d{1,2})\s*:\s*(\d{2})/.exec(i??"");if(!c)return[];const o=Number(c[1]),u=c[2];return Number.isFinite(o)?Array.from({length:5},(f,d)=>o+d).filter(f=>f<=23).map(f=>`${String(f).padStart(2,"0")}:${u}`):[]}function vy(i){return eu(i.units).map(c=>{var u;const o=f=>{var h;const d=Number((h=At(c.facts,f))==null?void 0:h.replace(/[^0-9]/g,""));return Number.isFinite(d)&&d>0?d:void 0};return{unitId:c.unitId,name:c.name,weekdayPrice:(u=yp(c))==null?void 0:u.price,weekendPrice:o("weekend_price"),maxCapacity:o("max_capacity")}})}function yy(){var se;const i=le(),c=q.useMemo(()=>vy(i),[i]),o=q.useMemo(()=>{var Q;return((Q=Bs(i.facts).find(ae=>ae.key==="check_in_time"))==null?void 0:Q.value)??void 0},[i]),u=q.useMemo(()=>by(o),[o]),[f,d]=q.useState(null),[h,p]=q.useState(0);q.useEffect(()=>d(new Date),[]);const g=q.useMemo(()=>f?new Date(f.getFullYear(),f.getMonth()+h,1):null,[f,h]),x=q.useMemo(()=>f&&g?gy(g.getFullYear(),g.getMonth(),f):[],[f,g]),[v,y]=q.useState(null),[z,w]=q.useState(null),[N,A]=q.useState(((se=c[0])==null?void 0:se.unitId)??null),[D,U]=q.useState(2),[X,Z]=q.useState(!1),P=x.find(Q=>Q!==null&&Q.iso===v)??null,$=c.find(Q=>Q.unitId===N)??null,K=($==null?void 0:$.maxCapacity)??8,G=P&&$?P.isWeekend?$.weekendPrice??$.weekdayPrice:$.weekdayPrice:void 0,ee=!!(v&&$&&(u.length===0||z));return q.useEffect(()=>{U(Q=>Math.min(Q,($==null?void 0:$.maxCapacity)??8))},[$]),c.length===0?null:n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("div",{className:"border-b border-black/8 px-4 py-3 sm:px-5",children:n.jsxs("p",{className:"flex items-center gap-2 text-xs font-bold",children:[n.jsx(Nb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"날짜 · 시간 선택"})]})}),f===null||g===null?n.jsx("p",{className:"px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5",children:"날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 확인해 주세요."}):X?n.jsx(jy,{payload:i,onReset:()=>Z(!1),summary:[P?`${g.getMonth()+1}월 ${P.day}일(${Kh[P.weekday]})`:null,z?`도착 ${z}`:null,($==null?void 0:$.name)??null,`${D}명`].filter(Q=>!!Q).join(" · ")}):n.jsxs("div",{className:"space-y-5 p-4 sm:p-5",children:[n.jsxs("div",{children:[n.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"날짜"}),n.jsxs("div",{className:"flex items-center gap-1",children:[n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.max(0,Q-1)),disabled:h===0,"aria-label":"이전 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(oa,{className:"size-3.5"})}),n.jsxs("span",{className:"w-24 text-center text-xs font-bold",children:[g.getFullYear(),"년 ",g.getMonth()+1,"월"]}),n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.min(Jh,Q+1)),disabled:h>=Jh,"aria-label":"다음 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(ua,{className:"size-3.5"})})]})]}),n.jsx("div",{className:"grid grid-cols-7 gap-1 border-b border-black/8 pb-1.5",children:Kh.map((Q,ae)=>n.jsx("span",{className:"text-center text-[10px] font-semibold",style:{opacity:ae===0||ae===6?.75:.45},children:Q},Q))}),n.jsx("div",{className:"mt-1.5 grid grid-cols-7 gap-1",children:x.map((Q,ae)=>Q===null?n.jsx("span",{"aria-hidden":!0},`pad-${ae}`):n.jsx("button",{type:"button",disabled:Q.past,onClick:()=>y(Q.iso),"aria-pressed":Q.iso===v,"aria-label":`${g.getMonth()+1}월 ${Q.day}일`,className:"flex h-9 items-center justify-center rounded-lg border text-xs transition-colors disabled:cursor-not-allowed",style:{borderColor:Q.iso===v?"var(--color-brand)":"transparent",backgroundColor:Q.iso===v?"var(--color-brand)":"var(--color-surface-alt)",color:Q.iso===v?"#fff":void 0,opacity:Q.past?.25:1,fontWeight:Q.iso===v?700:400},children:Q.day},Q.iso))}),(P==null?void 0:P.isWeekend)&&n.jsx("p",{className:"mt-2 text-[11px] opacity-55",children:"주말 요금이 적용되는 날짜입니다."})]}),u.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"mb-2 flex items-center gap-1.5 text-[11px] font-semibold opacity-55",children:[n.jsx(Cb,{className:"size-3"}),n.jsxs("span",{children:["도착 예정 시간 (체크인 ",o," 이후)"]})]}),n.jsx("ul",{className:"flex flex-wrap gap-1.5",children:u.map(Q=>{const ae=Q===z;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>w(Q),"aria-pressed":ae,className:"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:ae?"var(--color-brand)":"var(--color-surface-alt)",color:ae?"#fff":void 0},children:Q})},Q)})})]}),n.jsxs("div",{children:[n.jsx("p",{className:"mb-2 text-[11px] font-semibold opacity-55",children:"객실"}),n.jsx("ul",{className:"grid gap-1.5 sm:grid-cols-2",children:c.map(Q=>{const ae=Q.unitId===N;return n.jsx("li",{children:n.jsxs("button",{type:"button",onClick:()=>A(Q.unitId),"aria-pressed":ae,className:"flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left text-xs transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{className:"font-semibold",children:Q.name}),Q.maxCapacity&&n.jsxs("span",{className:"shrink-0 opacity-55",children:["최대 ",Q.maxCapacity,"명"]})]})},Q.unitId)})})]}),n.jsxs("div",{className:"flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"인원"}),n.jsxs("div",{className:"flex items-center gap-3",children:[n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.max(1,Q-1)),"aria-label":"인원 줄이기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Gb,{className:"size-3.5"})}),n.jsxs("span",{className:"w-10 text-center text-sm font-bold",children:[D,"명"]}),n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.min(K,Q+1)),"aria-label":"인원 늘리기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Kb,{className:"size-3.5"})})]})]}),n.jsxs("div",{className:"rounded-xl border border-black/8 p-3",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[n.jsx("span",{className:"text-xs opacity-60",children:P?`${g.getMonth()+1}월 ${P.day}일 · ${($==null?void 0:$.name)??""} · ${D}명`:"날짜를 골라 주세요"}),G!=null&&n.jsxs("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:[G.toLocaleString("ko-KR"),"원"]})]}),G!=null&&n.jsx("p",{className:"mt-1 text-[11px] opacity-50",children:"1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다."})]}),n.jsx("button",{type:"button",disabled:!ee,onClick:()=>Z(!0),className:"w-full rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40",style:{backgroundColor:"var(--color-brand)"},children:"예약 요청 확인하기"})]})]})}function jy({payload:i,summary:c,onReset:o}){const u=i.place.phone;return n.jsxs("div",{className:"space-y-4 p-4 sm:p-5",children:[n.jsxs("div",{className:"flex items-start gap-2.5",children:[n.jsx("span",{className:"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-white",style:{backgroundColor:"var(--color-brand)"},children:n.jsx(dp,{className:"size-4"})}),n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"text-sm font-bold",children:"예약 내용 확인"}),n.jsx("p",{className:"mt-0.5 text-xs leading-relaxed opacity-70",children:c})]})]}),n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row",children:[u&&n.jsxs("a",{href:`tel:${u}`,className:"flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(_t,{className:"size-4"}),n.jsxs("span",{children:["전화로 예약하기 ",u]})]}),n.jsxs("button",{type:"button",onClick:o,className:"flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",children:[n.jsx(Wb,{className:"size-3.5"}),n.jsx("span",{children:"다시 고르기"})]})]})]})}function Fh(){const i=le(),c=W0(i);if(!c)return null;const{offers:o,notices:u,links:f,contacts:d,phone:h}=c;return n.jsx("section",{id:"booking","aria-labelledby":"booking-heading",className:"w-full border-b border-black/8 py-16 sm:py-24",style:{backgroundColor:"var(--color-surface-alt)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("p",{className:"mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50",children:[n.jsx(yb,{className:"size-4"}),n.jsx("span",{children:"Reservation"})]}),n.jsx("h2",{id:"booking-heading",className:"serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl",children:Ye(i,"booking","예약 안내")}),n.jsx("p",{className:"mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm",children:"빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만 안내합니다."}),n.jsxs("div",{className:"grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6",children:[o.length>0&&n.jsxs("div",{className:"overflow-hidden rounded-2xl border border-black/8 lg:col-span-3",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(bb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"객실별 요금 · 인원"})]}),n.jsx("ul",{className:"divide-y divide-black/5",children:o.map(p=>n.jsxs("li",{className:"p-4 sm:p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:[n.jsx("h3",{className:"serif text-base font-bold sm:text-lg",children:p.name}),p.baseRateText&&n.jsx("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:p.baseRateText})]}),p.capacityText&&n.jsx("p",{className:"mt-1 text-xs opacity-60",children:p.capacityText}),p.rateRows.length>0&&n.jsx("dl",{className:"mt-3 flex flex-wrap gap-x-4 gap-y-1.5",children:p.rateRows.map(g=>n.jsxs("div",{className:"flex items-baseline gap-1.5 text-xs",children:[n.jsx("dt",{className:"opacity-50",children:g.label}),n.jsx("dd",{className:"font-semibold",children:g.value})]},g.label))}),n.jsx("a",{href:p.href,className:"mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70",children:n.jsxs("span",{children:[p.name," 사진 · 상세 보기"]})})]},p.unitId))})]}),n.jsxs("div",{className:"flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("p",{className:"text-xs font-bold",children:"예약 창구"}),h&&n.jsxs("a",{href:`tel:${h}`,className:"flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(_t,{className:"size-4"}),n.jsxs("span",{children:["전화 예약 ",h]})]}),f.map(p=>n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{children:Sp(p)}),n.jsx(Sn,{className:"size-3.5 shrink-0"})]},p.url)),d.length>0&&n.jsxs("div",{className:"mt-1 border-t border-black/8 pt-3",children:[n.jsx("p",{className:"mb-2 text-xs opacity-50",children:"문의"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:d.map(p=>n.jsx("li",{children:n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5",children:[n.jsx("span",{children:ml(p)}),n.jsx(Sn,{className:"size-3"})]})},p.url))})]}),f.length===0&&n.jsx("p",{className:"text-xs leading-relaxed opacity-55",children:"온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요."})]})]}),n.jsx(yy,{}),u.length>0&&n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(Ib,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"예약 전 확인"})]}),n.jsx("dl",{className:"grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0",children:u.map(p=>n.jsxs("div",{className:"flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5",children:[n.jsx("dt",{className:"shrink-0 text-xs opacity-55",children:p.label}),n.jsx("dd",{className:"text-xs font-semibold sm:max-w-[60%] sm:text-right",children:p.value})]},p.label))})]})]})})}function Ny(){const i=le(),c=G0(i);return c.length===0?null:n.jsx(vt,{id:"space",title:Ye(i,"space","공간 안내"),lead:"좌석과 이용 환경 안내입니다.",children:n.jsx("dl",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4",children:c.map(o=>n.jsxs(bu,{className:"p-4 sm:p-5",children:[n.jsx("dt",{className:"text-muted mb-1 text-[length:var(--fs-xs)] font-medium",children:o.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:o.value})]},o.label))})})}function Sy(){const i=le(),{place:c}=i,o=wn(i);if(!c.phone&&!c.email&&o.length===0)return null;const u=[...c.phone?[{key:"phone",icon:_t,label:"전화",value:c.phone,href:`tel:${c.phone}`}]:[],...c.email?[{key:"email",icon:mp,label:"이메일",value:c.email,href:`mailto:${c.email}`}]:[],...o.map(f=>({key:f.url,icon:Io,label:ml(f),value:"바로 가기",href:f.url,external:!0}))];return n.jsx(vt,{id:"inquiry",title:Ye(i,"inquiry","문의 안내"),lead:`궁금한 점은 아래로 연락 주시면 ${c.name}에서 직접 안내해 드립니다.`,children:n.jsx("ul",{className:"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 lg:gap-4",children:u.map(f=>{const d=f.icon,h="external"in f&&f.external;return n.jsx("li",{children:n.jsxs("a",{href:f.href,...h?{target:"_blank",rel:"noopener noreferrer"}:{},className:"panel flex h-full items-center justify-between gap-3 p-5 transition-opacity hover:opacity-80",children:[n.jsxs("span",{className:"flex min-w-0 items-center gap-3",children:[n.jsx(d,{className:"size-5 shrink-0 opacity-40"}),n.jsxs("span",{className:"min-w-0",children:[n.jsx("span",{className:"text-muted block text-[length:var(--fs-xs)] font-medium",children:f.label}),n.jsx("span",{className:"block break-all text-[length:var(--fs-sm)] font-semibold",children:f.value})]})]}),h&&n.jsx(Sn,{className:"size-4 shrink-0 opacity-40"})]})},f.key)})})})}function zy(){const i=le(),c=Q0(i);return c.length===0?null:n.jsx(vt,{id:"exhibition",tone:"alt",title:Ye(i,"exhibition","관람 안내"),lead:"방문 전 관람 조건을 확인해 주세요.",children:n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5",children:c.map(o=>n.jsx(kp,{label:o.label,value:o.value},o.label))})})}function Ey(){const[i,c]=q.useState([]);return q.useEffect(()=>c(w0()),[]),i}const wy=["봄","여름","가을","겨울"];function Ty(){const i=le(),c=i.local.festivals,o=Ey(),[u,f]=q.useState(null);if(c.length===0)return null;const d=wy.filter(y=>c.some(z=>z.season===y)),h=c.filter(y=>{var z;return!((z=y.season)!=null&&z.trim())}),p=[...o].reverse().find(y=>d.includes(y)),g="전체",x=u??p??null,v=x===g;return n.jsxs(vt,{id:"festival",title:"계절별 축제",lead:`${i.place.addressLocality??"이 지역"}의 축제와 행사를 계절로 묶었습니다.`,aside:x&&!v?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:["지금은 ",x," 축제입니다"]}):void 0,footnote:n.jsx(n.Fragment,{children:"한국관광공사 TourAPI 기준. 일정은 주최 측 사정으로 바뀔 수 있습니다."}),children:[d.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"계절",children:[...d,g].map(y=>{const z=x===y;return n.jsx("button",{type:"button",role:"tab","aria-selected":z,onClick:()=>f(y),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:z?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:y},y)})}),n.jsxs("div",{className:"space-y-8",children:[d.map(y=>{const z=c.filter(w=>w.season===y);return n.jsxs("div",{hidden:!v&&x!==null&&x!==y,children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:y}),n.jsx(Ih,{items:z,label:`${y} 축제`,remount:x})]},y)}),h.length>0&&n.jsxs("div",{children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:"계절 없이 열리는 행사"}),n.jsx(Ih,{items:h,label:"계절 없이 열리는 행사"})]})]})]})}function Ih({items:i,label:c,remount:o}){return n.jsx(gu,{label:c,children:i.map(u=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(u.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[u.imageUrl?n.jsx("img",{src:u.imageUrl,alt:`${u.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:u.name}),n.jsx("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:u.month})]}),n.jsxs("span",{className:"flex min-w-0 flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:u.name}),u.period&&n.jsx("span",{className:"text-[length:var(--fs-xs)] font-medium opacity-80",children:u.period}),u.location&&n.jsxs("span",{className:"text-muted flex items-start gap-1 text-[length:var(--fs-xs)]",children:[n.jsx(Ls,{className:"mt-0.5 size-3 shrink-0 opacity-60"}),n.jsx("span",{children:u.location})]}),u.description&&n.jsx("span",{className:"text-muted line-clamp-3 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed",children:u.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},u.name))},o??"all")}const _s=80,Ph=[{id:"all",label:"전체",test:()=>!0},{id:"w5",label:"걸어서 5분 이내",test:i=>i<=5*_s},{id:"w10",label:"걸어서 10분 이내",test:i=>i<=10*_s},{id:"far",label:"걸어서 10분 이상",test:i=>i>10*_s}];function Bo(i){if(Number.isFinite(i))return`도보 약 ${Math.max(1,Math.round(i/_s))}분`}function Cs(i){const c=/^([\d.]+)\s*(km|m)$/i.exec((i??"").trim());return c?Number(c[1])*(c[2].toLowerCase()==="km"?1e3:1):1/0}function Ay(){const i=le(),{local:c}=i,o=c.restaurants.length>0||c.attractions.length>0,[u,f]=q.useState("all");if(!o)return null;const d=[...c.restaurants,...c.attractions],h=v=>d.filter(y=>v.test(Cs(y.distanceText))).length,p=Ph.filter((v,y)=>y===0||h(v)>0&&h(v)<d.length),g=p.find(v=>v.id===u)??Ph[0],x=v=>g.test(Cs(v.distanceText));return n.jsxs(vt,{id:"guide",tone:"alt",title:"주변 안내",lead:`${i.place.addressLocality??"주변"} 지역의 맛집 · 명소 안내입니다.`,aside:c.syncedAt?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:[nu(c.syncedAt)," 갱신"]}):void 0,footnote:n.jsxs(n.Fragment,{children:["도보 시간은 숙소에서 잰 직선거리를 분속 ",_s,"m 로 환산한 값입니다. 실제로 걷는 길은 이보다 깁니다."]}),children:[p.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"거리",children:p.map(v=>{const y=v.id===u,z=h(v);return n.jsxs("button",{type:"button",role:"tab","aria-selected":y,onClick:()=>f(v.id),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:y?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[v.label," ",n.jsx("span",{className:"tabular-nums opacity-70",children:z})]},v.id)})}),n.jsxs("div",{className:"space-y-10",children:[c.restaurants.length>0&&n.jsx(ep,{title:"주변 맛집",icon:e0,places:c.restaurants,within:x}),c.attractions.length>0&&n.jsx(ep,{title:"주변 명소",icon:Ls,places:c.attractions,within:x})]})]})}function _y({icon:i,children:c}){return n.jsxs("h3",{className:"border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(i,{className:"size-4 opacity-50"}),n.jsx("span",{children:c})]})}function ep({title:i,icon:c,places:o,within:u}){const f=o.filter(u);return n.jsxs("div",{children:[n.jsx(_y,{icon:c,children:i}),n.jsx(gu,{label:i,children:f.map(d=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(d.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[d.imageUrl?n.jsx("img",{src:d.imageUrl,alt:`${d.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:d.name}),d.distanceText&&n.jsxs("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:[Bo(Cs(d.distanceText))??d.distanceText,n.jsx("span",{className:"ml-1 font-normal opacity-70",children:d.distanceText})]})]}),n.jsxs("span",{className:"flex flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:d.name}),d.description&&n.jsx("span",{className:"text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed",children:d.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},d.name))},f.length),n.jsx("ul",{hidden:!0,children:o.filter(d=>!u(d)).map(d=>n.jsxs("li",{children:[n.jsx("a",{href:Vo(d.searchQuery),rel:"nofollow",children:d.name}),d.distanceText&&n.jsxs("span",{children:[" ",d.distanceText,Bo(Cs(d.distanceText))&&` · ${Bo(Cs(d.distanceText))}`]}),d.description&&n.jsx("p",{children:d.description})]},d.name))}),f.length===0&&n.jsx("p",{className:"text-muted text-[length:var(--fs-sm)]",children:"이 거리 안에는 없습니다."})]})}function Cy(i){return i===0?"맑음":i>=1&&i<=3?"구름많음":i>=50&&i<=69?"비":i>=70&&i<=79?"눈":"흐림"}function My({initial:i,regionCode:c,latitude:o,longitude:u}){const[f,d]=q.useState(i);return q.useEffect(()=>{if(!c||o==null||u==null)return;const h=new AbortController;async function p(){var x;try{const v=new URLSearchParams({region_code:c,latitude:String(o),longitude:String(u)}),y=await fetch(`/v1/local/weather?${v}`,{signal:h.signal});if(!y.ok)return;const z=await y.json();if(!((x=z==null?void 0:z.result)!=null&&x.success)||!z.weather)return;d(w=>({temperature:Number(z.weather.temperature),condition:Cy(Number(z.weather.weather_code)),note:w==null?void 0:w.note,observedAt:z.weather.observed_at,stale:!!z.stale}))}catch{}}p();const g=window.setInterval(()=>void p(),600*1e3);return()=>{h.abort(),window.clearInterval(g)}},[o,u,c]),f}function ky(i){const c=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(i??"");if(!c)return;const[,,o,u,f,d]=c;return`${Number(o)}월 ${Number(u)}일 ${f}:${d} 관측`}function Oy(){var p,g,x,v;const i=le(),c=My({initial:i.local.weather,regionCode:i.place.regionCode,latitude:i.place.latitude,longitude:i.place.longitude});if(!c)return null;const o=O0(c.condition),u=D0(c.temperature),f=((g=(p=i.local.weather)==null?void 0:p.notes)==null?void 0:g[o])??c.note,d=(v=(x=i.local.weather)==null?void 0:x.tempNotes)==null?void 0:v[u],h=ky(c.observedAt);return n.jsx(vt,{id:"weather",tone:"alt",title:"오늘의 날씨",children:n.jsxs(bu,{className:"relative overflow-hidden",children:[n.jsx("div",{className:"w4-sky","data-mood":o,"aria-hidden":!0}),n.jsxs("div",{className:"relative flex flex-col gap-5 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-4 gap-y-1",children:[n.jsxs("p",{className:"flex items-baseline gap-2.5",children:[n.jsx("span",{className:"serif tabular-nums leading-none",style:{fontSize:"var(--fs-display)"},children:Math.round(c.temperature)}),n.jsx("span",{className:"text-[length:var(--fs-lead)] opacity-70",children:"°C"}),n.jsx("span",{className:"text-[length:var(--fs-lead)] font-semibold",children:c.condition})]}),h&&n.jsxs("span",{className:"text-muted text-[length:var(--fs-xs)] tabular-nums",children:[h,c.stale&&" · 최근 관측값"]})]}),f&&n.jsx("p",{className:"measure serif text-[length:var(--fs-lead)] leading-loose opacity-85",children:f}),d&&n.jsxs("p",{className:"measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:[n.jsx("span",{className:"border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold","aria-hidden":!0,children:u}),n.jsx("span",{children:d})]})]})]})})}function Dy(){const i=le(),c=lu(i),o=i.theme.sections.find(A=>A.id==="photos"),u=(o==null?void 0:o.variantId)??"photos.grid",[f,d]=q.useState(null),h=q.useRef(null),[p,g]=q.useState(0),[x,v]=q.useState({prev:!1,next:!0}),y=q.useCallback(()=>{const A=h.current;if(!A)return;const D=A.clientWidth,U=A.scrollWidth-D;g(D>0?Math.round(A.scrollLeft/D):0),v({prev:A.scrollLeft>8,next:A.scrollLeft<U-8})},[]);q.useEffect(()=>(y(),window.addEventListener("resize",y),()=>window.removeEventListener("resize",y)),[y]),xu({box:h,interval:pu,advance:()=>Cp(h.current,1)});const z=q.useCallback(A=>{const D=h.current;if(!D)return;const U=window.matchMedia("(prefers-reduced-motion: reduce)").matches;D.scrollBy({left:A*D.clientWidth,behavior:U?"auto":"smooth"})},[]),w=q.useCallback(()=>d(null),[]),N=q.useCallback(A=>d(D=>D===null?null:(D+A+c.length)%c.length),[c.length]);return q.useEffect(()=>{if(f===null)return;const A=U=>{U.key==="Escape"&&w(),U.key==="ArrowLeft"&&N(-1),U.key==="ArrowRight"&&N(1)},D=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",A),()=>{document.body.style.overflow=D,window.removeEventListener("keydown",A)}},[f,w,N]),c.length===0?null:n.jsxs(vt,{id:"gallery",title:(o==null?void 0:o.name)||"공간 갤러리",children:[u==="photos.carousel"?n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:h,onScroll:y,className:"flex snap-x snap-mandatory gap-3 overflow-x-auto [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden",children:c.map((A,D)=>n.jsx("li",{className:"w-full shrink-0 snap-center lg:w-auto",children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"aspect-4/3 rounded-lg"})},A.mediaId))}),c.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(tp,{dir:"prev",show:x.prev,onClick:()=>z(-1)}),n.jsx(tp,{dir:"next",show:x.next,onClick:()=>z(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden",children:[Math.min(p+1,c.length)," / ",c.length]})]})]}):u==="photos.masonry"?n.jsx("ul",{className:"columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3",children:c.map((A,D)=>n.jsx("li",{className:"break-inside-avoid",children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"rounded-lg",free:!0})},A.mediaId))}):n.jsx("ul",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4",children:c.map((A,D)=>n.jsx("li",{children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"aspect-square rounded-lg"})},A.mediaId))}),f!==null&&n.jsxs("div",{role:"dialog","aria-modal":"true","aria-label":"사진 크게 보기",className:"fixed inset-0 z-50 flex items-center justify-center bg-black/92 p-4 backdrop-blur-md",onClick:w,children:[n.jsx("button",{type:"button",onClick:w,"aria-label":"닫기",className:"tap absolute right-3 top-3 z-10 flex items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-5 sm:top-5",children:n.jsx(pp,{className:"size-6"})}),n.jsx(lp,{dir:"prev",onClick:()=>N(-1)}),n.jsx(lp,{dir:"next",onClick:()=>N(1)}),n.jsxs("figure",{className:"flex max-h-[85vh] w-full max-w-5xl flex-col items-center gap-3",onClick:A=>A.stopPropagation(),children:[n.jsx("img",{src:c[f].url,alt:c[f].alt,className:"max-h-[72vh] w-auto max-w-full rounded-lg object-contain"}),n.jsxs("figcaption",{className:"text-center text-[length:var(--fs-xs)] text-white/70",children:[c[f].caption??c[f].alt,n.jsxs("span",{className:"ml-2 tabular-nums",children:[f+1," / ",c.length]})]})]})]})]})}function tp({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-5"})})}function lp({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:u=>{u.stopPropagation(),c()},"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 ${i==="prev"?"left-2 sm:left-6":"right-2 sm:right-6"}`,children:n.jsx(o,{className:"size-6"})})}function qo({image:i,onOpen:c,className:o,free:u=!1}){return n.jsx("button",{type:"button",onClick:c,className:`border-line tpl-border group relative block w-full cursor-pointer overflow-hidden border ${o}`,"aria-label":`${i.alt} 크게 보기`,children:n.jsx("img",{src:i.url,alt:i.alt,loading:"lazy",decoding:"async",width:i.width,height:i.height,className:u?"h-auto w-full":"size-full object-cover transition-transform duration-500 group-hover:scale-105"})})}function ap(){const i=le(),{place:c,routes:o}=i,[u,f]=q.useState(!1),d=c.roadAddress??c.address;if(!d)return null;const{latitude:h,longitude:p}=c,g=h!=null&&p!=null,x=jv(c.name,h,p),v=async()=>{try{await navigator.clipboard.writeText(d),f(!0),setTimeout(()=>f(!1),2e3)}catch{}};return n.jsxs(vt,{id:"location",title:"오시는 길",lead:"주소와 주요 거점까지의 이동 시간을 안내합니다.",children:[n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-8",children:[g&&n.jsx("div",{className:"border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7",children:n.jsx("iframe",{title:`${c.name} 위치 지도`,src:yv(h,p),loading:"lazy",referrerPolicy:"no-referrer-when-downgrade",className:"block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"})}),n.jsx("div",{className:g?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:"panel p-5 sm:p-6",children:[n.jsx("p",{className:"label mb-2",children:"주소"}),n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[n.jsx("address",{className:"text-[length:var(--fs-lead)] font-bold not-italic",children:d}),n.jsxs("button",{type:"button",onClick:v,title:"주소 복사",className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70",children:[u?n.jsx(dp,{className:"size-3.5"}):n.jsx(kb,{className:"size-3.5"}),n.jsx("span",{children:u?"복사완료":"복사"})]})]}),c.phone&&n.jsxs("p",{className:"text-muted mt-2 text-[length:var(--fs-sm)]",children:["문의:"," ",n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2.5",children:[n.jsxs("a",{href:wp(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"네이버 길찾기"})]}),n.jsxs("a",{href:bv(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"카카오 길찾기"})]}),x&&n.jsxs("a",{href:x,className:"tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"티맵 길찾기"})]})]})]})})]}),o.length>0&&n.jsxs("div",{className:"mt-8",children:[n.jsxs("h3",{className:"mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(zb,{className:"size-4 opacity-50"}),n.jsx("span",{children:"주요 거점 소요시간"})]}),n.jsx("div",{className:"panel overflow-hidden",children:n.jsx("div",{className:"slider-viewport",children:n.jsxs("table",{className:"w-full min-w-[34rem] text-left text-[length:var(--fs-sm)]",children:[n.jsxs("caption",{className:"sr-only",children:[i.place.name,"에서 주요 거점까지의 이동 시간"]}),n.jsx("thead",{className:"panel-sunken border-line border-b font-semibold",children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",className:"p-4",children:"목적지"}),n.jsx("th",{scope:"col",className:"p-4",children:"차량"}),n.jsx("th",{scope:"col",className:"p-4",children:"도보 / 대중교통"}),n.jsx("th",{scope:"col",className:"p-4",children:"거리"}),n.jsx("th",{scope:"col",className:"hidden p-4 md:table-cell",children:"비고"})]})}),n.jsx("tbody",{className:"divide-line divide-y",children:o.map(y=>n.jsxs("tr",{children:[n.jsx("th",{scope:"row",className:"p-4 text-left font-bold",children:y.destination}),n.jsx("td",{className:"p-4 font-semibold",children:y.byCar??"—"}),n.jsx("td",{className:"p-4 opacity-70",children:y.byTransit??"—"}),n.jsx("td",{className:"text-muted p-4 tabular-nums",children:y.distanceText??"—"}),n.jsx("td",{className:"text-muted hidden p-4 md:table-cell",children:y.note??""})]},y.destination))})]})})})]})]})}function Ry(){const i=le(),c=R0(i);if(c.length===0)return null;const o=c.some(u=>u.sourceType===f0.TEMPLATE);return n.jsx(vt,{id:"faq",tone:"alt",title:"자주 묻는 질문",lead:o?void 0:"아래 답변은 모두 사업자가 확인한 내용입니다.",children:n.jsx("div",{className:c.length>4?"grid gap-3 lg:grid-cols-2 lg:gap-4":"space-y-3",children:c.map((u,f)=>n.jsxs("details",{open:f===0,className:"panel group h-fit overflow-hidden",children:[n.jsxs("summary",{className:"tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsx("span",{className:"serif text-muted shrink-0",children:"Q."}),n.jsx("span",{className:"flex-1",children:u.question}),n.jsx("span",{className:"text-muted shrink-0 transition-transform group-open:rotate-45",children:"+"})]}),n.jsx("div",{className:"border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:u.answer})]},u.faqId))})})}function Op(){var d,h,p,g;const i=le(),{place:c,site:o}=i,u=A0(i),f=c.roadAddress??c.address;return n.jsx("footer",{className:"w-full pb-28 pt-14 md:pb-14",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid grid-cols-1 gap-8 border-b border-current/15 pb-10 md:grid-cols-12",children:[n.jsxs("div",{className:"space-y-4 md:col-span-7",children:[n.jsx("p",{className:"serif text-[length:var(--fs-lead)] font-bold",children:c.name}),n.jsxs("div",{className:"space-y-2 text-[length:var(--fs-sm)] opacity-75",children:[f&&n.jsxs("p",{className:"flex items-start gap-2",children:[n.jsx(Ls,{className:"mt-1 size-4 shrink-0 opacity-60"}),n.jsx("span",{children:f})]}),c.phone&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(_t,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),c.email&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(mp,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-2 hover:underline",children:c.email})]})]})]}),u.length>0&&n.jsxs("nav",{"aria-label":"공식 채널",className:"md:col-span-5",children:[n.jsx("h2",{className:"label mb-3 !text-current opacity-60",children:"공식 채널"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:u.map(x=>n.jsx("li",{children:n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10",children:[n.jsx("span",{children:ml(x)}),n.jsx(Db,{className:"size-3.5"})]})},x.url))})]})]}),n.jsxs("div",{className:"flex flex-col gap-3 pt-6 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",c.name]}),((d=c.legal)==null?void 0:d.representative)&&n.jsxs("span",{children:["대표: ",c.legal.representative]}),((h=c.legal)==null?void 0:h.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",c.legal.businessRegistrationNumber]}),((p=c.legal)==null?void 0:p.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",c.legal.mailOrderNumber]}),((g=c.legal)==null?void 0:g.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호",": ",c.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}function Tn(){const i=le(),c=i.place.phone,u=Ze(i)[0],f=i.links.find(d=>d.confirmed&&/kakao/i.test(d.url));return!c&&!u&&!f?null:n.jsxs("nav",{"aria-label":"연락 · 예약",className:"border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 94%, transparent)"},children:[n.jsxs("a",{href:"#location",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":"오시는 길",children:[n.jsx(Ls,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"위치"})]}),f&&n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":ml(f),children:[n.jsx(Io,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"문의"})]}),c&&n.jsxs("a",{href:`tel:${c}`,className:"tap border-line flex flex-1 items-center justify-center gap-1.5 rounded-lg border text-[length:var(--fs-sm)] font-bold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]}),u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)})]})}const tl="var(--tpl-card, #fafafa)",Ce="var(--tpl-border, #d6d3d1)",We="color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)",Ms="var(--tpl-inverse, #1c1917)",ra="var(--tpl-bg, #ffffff)",ul="var(--tpl-text, #09090b)";function Uy({n:i}){return n.jsx("span",{className:"mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums",style:{backgroundColor:We,color:ra},children:i})}function Rl({id:i,name:c,subtitle:o,count:u,link:f,children:d,dark:h}){const p=hc(),g=p==="reservation"?iu:p==="oasi"?cu:p==="studio"?ru:p==="pastel"?ou:p==="editorial"?au:null,x=f?n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-75 transition-opacity hover:opacity-100 hover:underline",children:[f.label," →"]}):void 0;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{backgroundColor:h?Ms:"var(--tpl-surface, #fafafa)",color:h?ra:ul,paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[g?n.jsx(g,{id:i,title:c,lead:o,aside:x}):n.jsxs("header",{className:"mb-8 sm:mb-10",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2",children:[n.jsx("h2",{id:`${i}-heading`,className:"h2",children:c}),x]}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:o})]}),d,u&&n.jsx("p",{className:"mt-6 text-[length:var(--fs-xs)] opacity-60",children:u})]})})}function Gs({children:i,label:c,arrows:o="header",onSelect:u,onReady:f}){return n.jsx(gu,{label:c,gap:1,arrows:o,onSelect:u,onReady:f,children:i})}function fa({source:i,verified:c,imageCredit:o}){return!(i!=null&&i.name)&&!o?null:n.jsxs("p",{className:"flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70",children:[(i==null?void 0:i.name)&&n.jsxs("span",{children:["출처 ·"," ",i.url?n.jsx("a",{href:i.url,target:"_blank",rel:"noopener noreferrer nofollow",className:"underline underline-offset-2",children:i.name}):i.name]}),o&&n.jsxs("span",{children:["사진 · ",o]})]})}function Hs(i,c,o){return`총 ${i}${o}`}const np=We;function Dp(){const i=le(),c=dl(i,"songs"),[o,u]=q.useState(0);if(c.items.length===0)return null;const f=c.items[o]??c.items[0],d=h=>[h.artist,h.year?String(h.year):void 0,h.lyricist||h.composer?`작사 ${h.lyricist??"미상"} / 작곡 ${h.composer??"미상"}`:void 0,h.label].filter(Boolean).join(" · ");return n.jsxs(Rl,{id:"songs",name:c.title||Ye(i,"songs","가요 다방"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"곡"),children:[n.jsxs("div",{className:"tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]",style:{backgroundColor:Ms,color:ra,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsxs("div",{className:"relative mx-auto size-[220px]",children:[n.jsx("div",{className:"absolute inset-0 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 16%, transparent)"}}),n.jsx("div",{className:"w4-disc w4-spin absolute inset-2 rounded-full",style:{"--lbl":f.labelColor||np,"--w4-vinyl":Ms},children:n.jsx("span",{className:"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 30%, transparent)"}})}),n.jsxs("div",{"aria-hidden":!0,className:"absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6",children:[n.jsx("span",{className:"absolute inset-y-[3px] left-0 right-4 rounded-sm",style:{backgroundColor:"color-mix(in oklab, currentColor 65%, transparent)"}}),n.jsx("span",{className:"absolute -top-2 right-0 size-6 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 55%, transparent)"}})]})]}),n.jsx("div",{className:"min-w-0",children:c.items.map((h,p)=>n.jsxs("div",{hidden:p!==o,className:"space-y-3",children:[n.jsxs("p",{className:"text-[10px] tracking-[0.24em]",style:{color:We},children:["A면 · ",p+1," / ",c.items.length]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h2)] leading-tight",children:h.title}),n.jsx("p",{className:"text-[length:var(--fs-xs)] opacity-60",children:d(h)}),h.story&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:h.story}),h.connection&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed",style:{color:We},children:h.connection}),n.jsxs("a",{href:gv(`${h.title} ${h.artist??""}`.trim()),target:"_blank",rel:"noopener noreferrer nofollow",className:"inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",style:{color:We},children:[n.jsx(a0,{className:"size-4"}),n.jsx("span",{children:"유튜브에서 듣기"})]}),n.jsx("span",{className:"block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60",style:{borderColor:"color-mix(in oklab, currentColor 35%, transparent)"},children:"◎ 가사 대신 이야기 — 원문은 싣지 않습니다"}),n.jsx(fa,{source:h.source,verified:h.verified})]},`disc-${h.title}-${p}`))})]}),c.items.length>1&&n.jsx("div",{className:"mt-4 flex flex-wrap justify-center gap-4",children:c.items.map((h,p)=>n.jsxs("button",{type:"button",onClick:()=>u(p),"aria-pressed":p===o,className:"w-24 text-center",children:[n.jsx("span",{className:"w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105",style:{"--lbl":h.labelColor||np,"--w4-vinyl":Ms,boxShadow:p===o?`0 0 0 2px ${We}`:void 0}}),n.jsx("span",{className:"mt-2 block truncate text-[10px] leading-tight opacity-70",children:h.title})]},`${h.title}-${p}`))})]})}function Rp(){const i=le(),c=dl(i,"daily"),[o,u]=q.useState();if(q.useEffect(()=>{const g=new Date;u(`${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`)},[]),c.items.length===0)return null;const f=[...c.items].sort((g,x)=>g.monthDay.localeCompare(x.monthDay)),d=o===void 0?1:Math.max(0,(()=>{const g=f.findIndex(v=>v.monthDay===o);if(g>=0)return g;const x=f.findIndex(v=>v.monthDay>o);return x>=0?x:0})()),h=g=>f[(d+g+f.length)%f.length],p=f.length===1?[h(0)]:f.length===2?[h(0),h(1)]:[h(-1),h(0),h(1)];return n.jsx(Rl,{id:"daily",name:c.title||Ye(i,"daily","오늘의 한 장"),subtitle:c.subtitle,count:Hs(f.length,c.unverified,"장"),children:n.jsx("div",{className:"flex items-start justify-center gap-4 sm:gap-6",children:f.map((g,x)=>{const v=p.indexOf(g);return n.jsx(Hy,{page:g,hidden:v<0,isToday:p.length===1||v===1,muted:p.length>1&&v!==1},`${g.monthDay}-${x}`)})})})}function Hy({page:i,isToday:c,muted:o,hidden:u}){const[f,d]=i.monthDay.split("-");return n.jsxs("article",{hidden:u,className:`w4-paper shrink-0 border transition-opacity ${o?"hidden w-[168px] opacity-45 sm:block":"w-[254px] max-w-full"}`,style:{backgroundColor:tl,borderColor:c?We:Ce,boxShadow:c?"5px 6px 0 color-mix(in oklab, currentColor 14%, transparent)":void 0},"aria-current":c?"date":void 0,children:[n.jsx("div",{className:"w4-perf h-3.5 border-b border-dashed",style:{"--tear":tl,borderColor:Ce}}),n.jsxs("div",{className:"border-b px-5 pb-3 pt-5 text-center",style:{borderColor:Ce},children:[n.jsxs("p",{className:"text-[11px] tracking-[0.2em] opacity-60",children:[f,"月"]}),n.jsx("p",{className:`serif mt-1 font-bold leading-none ${o?"text-4xl":"text-6xl"}`,style:{color:We},children:String(Number(d)||d)}),c&&n.jsx("p",{className:"mt-1.5 text-[10px] tracking-[0.3em]",style:{color:We},children:"오늘"})]}),n.jsxs("div",{className:"space-y-2 px-5 pb-5 pt-4",children:[i.category&&n.jsx("p",{className:"text-[10px] tracking-[0.16em] opacity-60",children:i.category}),n.jsx("h3",{className:"serif text-base font-bold leading-snug",children:i.title}),!o&&i.body&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.body}),!o&&n.jsx("div",{className:"border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})]})}function Up(){const i=le(),c=dl(i,"people");return c.items.length===0?null:n.jsx(Rl,{id:"people",name:c.title||Ye(i,"people","인물 열전"),subtitle:c.subtitle,count:c.items.some(o=>!o.imageUrl)?`${Hs(c.items.length,c.unverified,"명")} · 사진이 없는 인물은 이름 활자로 대신합니다`:Hs(c.items.length,c.unverified,"명"),dark:!0,children:n.jsxs("div",{className:"tpl-border border",style:{backgroundColor:Ms,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"}),n.jsx("div",{className:"px-3 py-4",children:n.jsx(Gs,{label:"인물 목록",arrows:"overlay",children:c.items.map((o,u)=>n.jsxs("article",{className:"w-[210px] shrink-0 snap-center",children:[o.imageUrl?n.jsx("img",{src:o.imageUrl,alt:`${o.name} 사진`,loading:"lazy",decoding:"async",className:"aspect-3/4 w-full border object-cover",style:{borderColor:"color-mix(in oklab, currentColor 28%, transparent)"}}):n.jsx("span",{className:"serif grid aspect-3/4 w-full place-items-center border text-5xl",style:{backgroundColor:"color-mix(in oklab, currentColor 14%, transparent)",borderColor:"color-mix(in oklab, currentColor 28%, transparent)"},"aria-hidden":!0,children:o.name.trim().charAt(0)}),n.jsxs("h3",{className:"serif mt-3 text-base font-bold",children:[o.name,o.aka&&n.jsxs("span",{className:"ml-1.5 text-xs opacity-60",children:["호 ",o.aka]})]}),n.jsx("p",{className:"mt-0.5 text-[11px] opacity-60",children:[o.role,o.years].filter(Boolean).join(" · ")}),o.oneLine&&n.jsx("p",{className:"mt-2 text-[13px] leading-relaxed opacity-85",children:o.oneLine}),n.jsx("div",{className:"mt-2",children:n.jsx(fa,{source:o.source,verified:o.verified,imageCredit:o.imageCredit})})]},`${o.name}-${u}`))})}),n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"})]})})}function Hp(){const i=le(),c=dl(i,"chronicle");if(c.items.length===0)return null;const o=[...c.items].sort((f,d)=>f.year==null?d.year==null?0:1:d.year==null?-1:f.year-d.year),u=o.filter(f=>f.turning===!0).length;return n.jsx(Rl,{id:"chronicle",name:c.title||Ye(i,"chronicle","시간의 골목"),subtitle:c.subtitle,count:`붉은 점은 도시의 성격이 바뀐 해입니다 · ${u}개 / 전체 ${o.length}개`,children:n.jsx(Gs,{label:"연표",children:o.map((f,d)=>{const h=f.turning===!0;return n.jsxs("article",{className:"w-[228px] shrink-0 snap-center",children:[n.jsx("p",{className:"serif text-3xl font-bold leading-none",style:{color:h?We:void 0},children:f.year??"연도 미상"}),n.jsxs("div",{className:"relative my-3 h-3",children:[n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 border-t",style:{borderColor:Ce,right:d===o.length-1?"calc(100% - 12px)":0}}),n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2",style:{backgroundColor:h?We:tl,borderColor:h?We:Ce}})]}),n.jsxs("div",{className:"flex gap-2.5 pr-5",children:[f.imageUrl&&n.jsx("img",{src:f.imageUrl,alt:`${f.title} 사진`,loading:"lazy",decoding:"async",className:"size-14 shrink-0 border object-cover",style:{borderColor:Ce}}),n.jsxs("div",{className:"min-w-0 flex-1 space-y-1.5",children:[n.jsx("h3",{className:"serif text-base font-bold",children:f.title}),f.summary&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:f.summary}),f.place&&n.jsxs("p",{className:"text-[11px] opacity-60",children:["지금 이 자리 · ",f.place]}),n.jsx(fa,{source:f.source,verified:f.verified,imageCredit:f.imageCredit})]})]})]},`${f.year??"x"}-${f.title}-${d}`)})})})}function Lp(){const i=le(),c=dl(i,"postcard");return c.items.length===0?null:n.jsx(Rl,{id:"postcard",name:c.title||Ye(i,"postcard","오늘의 엽서"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"장"),children:n.jsx(Gs,{label:"엽서 목록",children:c.items.map((o,u)=>n.jsxs("article",{className:"w4-paper w-[304px] shrink-0 snap-center border p-4",style:{backgroundColor:tl,borderColor:ul},children:[o.imageUrl&&n.jsx("img",{src:o.imageUrl,alt:`${o.place||o.postmark||"엽서"} 사진`,loading:"lazy",decoding:"async",className:"block aspect-3/2 w-full border object-cover",style:{borderColor:Ce}}),o.imageUrl&&n.jsxs("p",{className:"mt-1 mb-3.5 text-[10px] leading-tight opacity-45",children:["사진 · ",o.imageCredit||"출처 표기 없음"]}),n.jsxs("div",{className:"grid grid-cols-[minmax(0,1fr)_auto] gap-3",children:[n.jsxs("div",{className:"min-w-0 border-r pr-3",style:{borderColor:Ce},children:[n.jsxs("p",{className:"serif text-[17px] leading-snug",children:["“",o.line,"”"]}),o.hashtags&&o.hashtags.length>0&&n.jsx("p",{className:"mt-2 text-[11px] opacity-60",children:o.hashtags.join(" ")})]}),n.jsxs("div",{className:"flex w-[62px] flex-col items-center gap-3",children:[n.jsxs("span",{className:"grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60",style:{borderColor:Ce},children:["郵票",n.jsx("br",{}),"10원"]}),n.jsx("span",{className:"serif grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight",style:{borderColor:We,color:We},children:o.postmark||o.place||"소인"})]})]}),n.jsx("div",{className:"mt-3 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:o.source,verified:o.verified})})]},`${o.line}-${u}`))})})}function Ly(){const i=le(),c=dl(i,"quiz"),[o,u]=q.useState(new Set);if(c.items.length===0)return null;const f=d=>u(h=>{const p=new Set(h);return p.has(d)?p.delete(d):p.add(d),p});return n.jsx(Rl,{id:"quiz",name:c.title||Ye(i,"quiz","뒤집어 보는 질문"),subtitle:c.subtitle,count:`정답은 두지 않습니다 — 힌트와 출처까지만 · 총 ${c.items.length}문항`,children:n.jsx(Gs,{label:"질문 목록",children:c.items.map((d,h)=>{const p=o.has(h);return n.jsx("button",{type:"button",onClick:()=>f(h),"aria-pressed":p,"aria-label":`${h+1}번 문제 ${p?"앞면 보기":"힌트 보기"}`,className:`w4-flip ${p?"w4-flip-on":""} h-[260px] w-[262px] shrink-0 text-left`,children:n.jsxs("span",{className:"w4-flip-inner block size-full",children:[n.jsxs("span",{className:"w4-flip-face w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:ul},children:[n.jsxs("span",{className:"text-[10px] tracking-[0.16em] opacity-60",children:["문제 ",h+1,d.level?` · ${d.level}`:""]}),n.jsx("span",{className:"serif text-[17px] font-bold leading-snug",children:d.question}),n.jsx("span",{className:"mt-auto text-[10px] tracking-[0.16em]",style:{color:We},children:"눌러서 힌트 보기 →"})]}),n.jsxs("span",{className:"w4-flip-face w4-flip-back w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:We},children:[n.jsx("span",{className:"text-[10px] tracking-[0.16em]",style:{color:We},children:"힌트"}),n.jsx("span",{className:"text-[13px] leading-relaxed opacity-85",children:d.hint??"힌트가 아직 없습니다."}),n.jsxs("span",{className:"mt-auto space-y-1 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:[d.topic&&n.jsx("span",{className:"block text-[10px] opacity-55",children:d.topic}),n.jsx(fa,{source:d.source,verified:d.verified})]})]})]})},`${d.question}-${h}`)})})})}const Dl=256,Ts=318,Nn=168,sp=34,By=16,ip=4;function Bp(i,c,o){const u=Dl*2**o,f=i*Math.PI/180;return{x:(c+180)/360*u,y:(1-Math.log(Math.tan(f)+1/Math.cos(f))/Math.PI)/2*u}}function qy(i){if(i.length<2)return 15;for(let c=By;c>ip;c-=1){const o=i.map(d=>Bp(d.lat,d.lng,c)),u=Math.max(...o.map(d=>d.x))-Math.min(...o.map(d=>d.x)),f=Math.max(...o.map(d=>d.y))-Math.min(...o.map(d=>d.y));if(u<=Ts-sp*2&&f<=Nn-sp*2)return c}return ip}function Yy(i,c){const o=d=>d.latitude!=null&&d.longitude!=null?{name:d.searchQuery??d.name,lat:d.latitude,lng:d.longitude}:void 0,u=[];let f=c;return i.forEach((d,h)=>{const p=o(d);p&&(u.push({label:String(h+1),...p,stop:d,from:f&&!Gy(f,p)?f:void 0}),f=p)}),u}function Gy(i,c){return Math.abs(i.lat-c.lat)<1e-5&&Math.abs(i.lng-c.lng)<1e-5}function $y(i){const o=i.map(u=>({...u}));for(let u=1;u<o.length;u+=1)for(let f=0;f<6;f+=1){const d=o.slice(0,u).find(v=>Math.hypot(o[u].x-v.x,o[u].y-v.y)<27);if(!d)break;const h=o[u].x-d.x,p=o[u].y-d.y,g=Math.hypot(h,p)||1,x=(27-g)/g;o[u].x+=(h||1)*x,o[u].y+=p*x}return o}function Qy(i){return i.from?vv(i.from,{name:i.stop.name,lat:i.lat,lng:i.lng}):wp(i.stop.searchQuery??i.stop.name,i.lat,i.lng)}function Xy({stops:i}){const{place:c}=le(),o=c.latitude!=null&&c.longitude!=null?{name:c.name,lat:c.latitude,lng:c.longitude}:void 0,u=i.filter(N=>N.latitude!=null&&N.longitude!=null);if(u.length===0||u.length===1&&i.length>1)return null;const f=Yy(i,o),d=qy(f),h=$y(f.map(N=>Bp(N.lat,N.lng,d))),p=(Math.min(...h.map(N=>N.x))+Math.max(...h.map(N=>N.x)))/2,g=(Math.min(...h.map(N=>N.y))+Math.max(...h.map(N=>N.y)))/2,x=p-Ts/2,v=g-Nn/2,y=2**d,z=[];for(let N=Math.floor(x/Dl);N<=Math.floor((x+Ts)/Dl);N+=1)for(let A=Math.floor(v/Dl);A<=Math.floor((v+Nn)/Dl);A+=1){if(A<0||A>=y)continue;const D=(N%y+y)%y;z.push({key:`${N}-${A}`,url:`https://tile.openstreetmap.org/${d}/${D}/${A}.png`,left:N*Dl-x,top:A*Dl-v})}const w=h.map(N=>`${(N.x-x).toFixed(1)},${(N.y-v).toFixed(1)}`).join(" ");return n.jsxs("div",{className:"relative mt-3.5 overflow-hidden border-y",style:{height:Nn,borderColor:Ce,backgroundColor:"#e8e5df"},children:[z.map(N=>n.jsx("img",{src:N.url,alt:"",width:Dl,height:Dl,loading:"lazy",decoding:"async",className:"pointer-events-none absolute max-w-none",style:{left:N.left,top:N.top,filter:"grayscale(1) contrast(0.95) opacity(0.55)"}},N.key)),n.jsx("svg",{className:"pointer-events-none absolute inset-0",width:Ts,height:Nn,viewBox:`0 0 ${Ts} ${Nn}`,children:n.jsx("polyline",{points:w,fill:"none",stroke:We,strokeWidth:"2",strokeDasharray:"5 4",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.85"})}),f.map((N,A)=>{const D=h[A];return n.jsx("a",{href:Qy(N),target:"_blank",rel:"noopener noreferrer",title:N.from?`${N.from.name} → ${N.stop.name} 길찾기`:`${N.stop.name} — 네이버 지도`,className:"absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full px-1.5 text-[11px] font-bold whitespace-nowrap shadow-sm transition-transform hover:scale-110",style:{left:D.x-x,top:D.y-v,backgroundColor:We,color:ra,border:`1.5px solid ${ra}`},children:N.label},N.label)}),n.jsx("span",{className:"absolute right-1 bottom-0.5 bg-white/70 px-1 text-[9px] leading-tight text-stone-600",children:"© OpenStreetMap"})]})}function Vy(i){const c=Math.floor(i/60),o=i%60;return[c>0?`${c}시간`:"",o>0?`${o}분`:""].filter(Boolean).join(" ")||"0분"}function Jo(i){var c;return(c=i.days)!=null&&c.length?i.days.map(o=>({label:o.label,startTime:o.startTime,stops:o.stops??[]})):[{startTime:i.startTime,stops:i.stops??[]}]}function Zy({item:i,badge:c,startTime:o,stops:u,first:f,placeName:d}){const h=E0({name:i.name,startTime:o,stops:u});return n.jsxs("article",{className:"w4-paper w-[320px] shrink-0 snap-center border",style:{backgroundColor:tl,borderColor:Ce},children:[n.jsxs("div",{className:"flex items-center justify-between gap-2 border-b px-4 py-2.5",style:{borderColor:Ce},children:[n.jsx("span",{className:"border px-2 py-0.5 text-[11px] font-bold",style:f?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:c}),n.jsxs("span",{className:"text-[11px] opacity-60",children:[h.from,"–",h.to," · ",Vy(h.totalMinutes)]})]}),n.jsxs("div",{className:"space-y-1.5 px-4 pt-3.5",children:[n.jsx("h3",{className:"serif text-lg font-bold",children:i.name}),f&&i.audience&&n.jsx("p",{className:"text-[12px] opacity-60",children:i.audience}),f&&i.why&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.why}),f&&n.jsxs("p",{className:"text-[11px] opacity-50",children:[d,"에서 출발 · ",h.from]})]}),n.jsx(Xy,{stops:h.stops.map(p=>p.stop)}),n.jsx("ol",{className:"mt-3 px-4 pb-3",children:h.stops.map((p,g)=>n.jsxs("li",{className:"grid grid-cols-[46px_minmax(0,1fr)] gap-2.5",children:[n.jsx("span",{className:"serif pt-2 text-[12px] tabular-nums opacity-75",children:p.time}),n.jsxs("div",{className:"border-l pb-5 pl-3",style:{borderColor:Ce},children:[p.move>0&&n.jsxs("p",{className:"pt-1 text-[10px] opacity-50",children:["↓ ",p.move,"분 이동"]}),n.jsxs("div",{className:"flex items-start gap-2.5 pt-1",children:[p.stop.imageUrl&&n.jsx("img",{src:p.stop.imageUrl,alt:`${p.stop.name} 사진`,loading:"lazy",decoding:"async",className:"size-12 shrink-0 object-cover",style:{border:`1px solid ${Ce}`}}),n.jsxs("span",{className:"min-w-0",children:[n.jsxs("span",{className:"block text-sm font-bold",children:[n.jsx(Uy,{n:g+1}),p.stop.name]}),p.stop.note&&n.jsx("span",{className:"mt-0.5 block text-[12px] leading-relaxed opacity-75",children:p.stop.note})]})]}),n.jsxs("p",{className:"mt-0.5 text-[10px] opacity-50",children:[p.time,"–",p.until,p.stop.searchQuery&&` · 지도 검색 ${p.stop.searchQuery}`]})]})]},`${p.stop.name}-${g}`))}),n.jsx("div",{className:"border-t border-dashed px-4 py-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})}const Yo="그 밖의 일정";function Ky(){var g;const i=le(),c=dl(i,"itinerary"),u=(c.items.length>0?c.items:((g=i.local)==null?void 0:g.itineraries)??[]).filter(x=>Jo(x).some(v=>v.stops.length>0)),f=[...new Set(u.map(x=>{var v;return((v=x.duration)==null?void 0:v.trim())||Yo}))],[d,h]=q.useState(null),p=d??f[0];return u.length===0?null:n.jsxs(Rl,{id:"itinerary",name:c.title||Ye(i,"itinerary","추천 일정"),subtitle:c.subtitle,count:"시각은 출발 시각과 머무는 시간으로 계산한 것입니다",children:[f.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"묵는 기간",children:f.map(x=>{const v=p===x;return n.jsxs("button",{type:"button",role:"tab","aria-selected":v,onClick:()=>h(x),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:v?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[x,n.jsx("span",{className:"ml-1.5 font-normal opacity-60",children:u.filter(y=>{var z;return(((z=y.duration)==null?void 0:z.trim())||Yo)===x}).length})]},x)})}),f.map(x=>n.jsx("div",{hidden:x!==p,children:n.jsx(Jy,{tab:x,items:u.filter(v=>{var y;return(((y=v.duration)==null?void 0:y.trim())||Yo)===x}),placeName:i.place.name},p)},x))]})}function Jy({tab:i,items:c,placeName:o}){const u=q.useMemo(()=>{let x=0;return c.map(v=>{const y=x;return x+=Jo(v).length,{item:v,start:y}})},[c]),[f,d]=q.useState(0),h=q.useRef(null),p=q.useCallback(x=>{h.current=x},[]),g=u.reduce((x,v)=>v.start<=f?v.start:x,0);return n.jsxs(n.Fragment,{children:[u.length>1&&n.jsx("ul",{className:"border-line mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3",children:u.map(({item:x,start:v})=>{const y=v===g;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>{var z;return(z=h.current)==null?void 0:z.scrollTo(v)},"aria-current":y,className:`text-left text-[length:var(--fs-sm)] underline-offset-4 transition-opacity hover:opacity-100 ${y?"font-bold underline":"opacity-55 hover:underline"}`,children:x.name})},`${x.name}-${v}`)})}),n.jsx(Gs,{label:`${i} 일정`,onSelect:d,onReady:p,children:u.flatMap(({item:x,start:v},y)=>Jo(x).map((z,w)=>n.jsx(Zy,{item:x,badge:z.label??x.duration??`${w+1}일차`,startTime:z.startTime,stops:z.stops,first:w===0,placeName:o},`${x.name}-${v}-${w}`)))})]})}function Wy(){const i=le(),c=dl(i,"video"),[o,u]=q.useState(),f=q.useRef(null),[d,h]=q.useState({prev:!1,next:!0}),p=q.useCallback(()=>{const v=f.current;if(!v)return;const y=v.scrollWidth-v.clientWidth;h({prev:v.scrollLeft>8,next:v.scrollLeft<y-8})},[]);q.useEffect(()=>(p(),window.addEventListener("resize",p),()=>window.removeEventListener("resize",p)),[p]);const g=q.useCallback(v=>{const y=f.current;if(!y)return;const z=window.matchMedia("(prefers-reduced-motion: reduce)").matches;y.scrollBy({left:v*y.clientWidth*.85,behavior:z?"auto":"smooth"})},[]);if(xu({box:f,interval:pu,advance:()=>Cp(f.current)}),c.items.length===0)return null;const x=c.items.length>1;return n.jsx(Rl,{id:"video",name:c.title||Ye(i,"video","영상으로 보기"),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,children:n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:f,onScroll:p,className:x?"flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:gap-4 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden":"grid grid-cols-1 gap-4",children:c.items.map((v,y)=>{const z=x0(v.url),w=g0(v.url),N=o===y;return n.jsx("li",{className:x?"shrink-0 basis-[86%] snap-start sm:basis-[56%] lg:basis-auto":"",children:n.jsxs("figure",{className:"space-y-2",children:[n.jsx("div",{className:"tpl-border relative mx-auto overflow-hidden border",style:{borderColor:Ce,backgroundColor:tl,aspectRatio:w?"9 / 16":"16 / 9",width:w?"min(100%, 22rem)":"100%"},children:N&&z?n.jsx("iframe",{src:v0(z),title:v.caption||"영상",allow:"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",allowFullScreen:!0,className:"size-full border-0"}):n.jsxs("a",{href:v.url,target:"_blank",rel:"noopener noreferrer",onClick:A=>{z&&(A.preventDefault(),u(y))},"aria-label":`${v.caption||"영상"} 재생`,className:"group block size-full",children:[z?n.jsx("img",{src:b0(z),alt:"",loading:"lazy",decoding:"async",className:"size-full object-cover"}):n.jsx("span",{className:"text-muted grid size-full place-items-center px-4 text-center text-[length:var(--fs-xs)]",children:"유튜브 영상이 아닙니다 — 눌러서 원본으로 갑니다"}),n.jsx("span",{className:"absolute inset-0 grid place-items-center",children:n.jsx("span",{className:"grid size-14 place-items-center rounded-full backdrop-blur transition-transform group-hover:scale-110",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 70%, transparent)",color:"var(--tpl-bg, #fff)"},children:n.jsx(Vb,{className:"size-6"})})})]})}),v.caption&&n.jsx("figcaption",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:v.caption}),n.jsx(fa,{source:v.source,verified:v.verified})]})},`${v.url}-${y}`)})}),x&&n.jsxs(n.Fragment,{children:[n.jsx(cp,{dir:"prev",onClick:()=>g(-1),disabled:!d.prev}),n.jsx(cp,{dir:"next",onClick:()=>g(1),disabled:!d.next})]})]})})}function cp({dir:i,onClick:c,disabled:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":i==="prev"?"이전 영상":"다음 영상",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 lg:hidden ${i==="prev"?"left-1":"right-1"}`,children:n.jsx(u,{className:"size-5"})})}const Fy={1:"한",2:"두",3:"세",4:"네",5:"다섯"},Iy=[{id:"songs",label:"가요 다방",Component:Dp},{id:"daily",label:"오늘의 한 장",Component:Rp},{id:"people",label:"인물 열전",Component:Up},{id:"chronicle",label:"시간의 골목",Component:Hp},{id:"postcard",label:"오늘의 엽서",Component:Lp}];function Py(){const i=le(),c=Iy.filter(f=>dl(i,f.id).items.length>0),[o,u]=q.useState(0);return c.length===0?null:n.jsxs(n.Fragment,{children:[n.jsx("section",{id:"story","aria-labelledby":"story-heading",className:"paper w-full",style:{backgroundColor:"var(--tpl-surface, #fafafa)",color:ul,paddingTop:"var(--section-space)",paddingBottom:"calc(var(--section-space) * 0.55)"},children:n.jsxs("div",{className:"shell",children:[n.jsx("h2",{id:"story-heading",className:"h2",children:Ye(i,"story","군산 이야기")}),n.jsxs("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:["이 도시를 ",Fy[c.length]??`${c.length}`," 갈래로 봅니다. 하나씩 골라 보세요."]}),n.jsx("div",{className:"mt-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"군산 이야기",children:c.map((f,d)=>{const h=d===o;return n.jsx("button",{type:"button",role:"tab","aria-selected":h,"aria-controls":f.id,onClick:()=>u(d),className:"rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:h?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:f.label},f.id)})})]})}),c.map((f,d)=>n.jsx("div",{hidden:d!==o,children:n.jsx(f.Component,{})},f.id))]})}const e1={open:"진행 중",soon:"곧 시작",closed:"종료"};function rp(i,c){return i.endDate&&i.endDate<c?"closed":i.startDate&&i.startDate>c?"soon":"open"}function Go(i,c){return i.kind==="공지"&&!i.startDate&&!i.endDate?"공지":c&&e1[c]}function $o(i){const c=f=>{const d=/^(\d{4})-(\d{2})-(\d{2})$/.exec(f??"");return d?`${Number(d[2])}월 ${Number(d[3])}일`:void 0},o=c(i.startDate),u=c(i.endDate);if(o&&u)return o===u?o:`${o} – ${u}`;if(o)return`${o}부터`;if(u)return`${u}까지`}function t1(){const i=le(),c=dl(i,"event"),[o,u]=q.useState();q.useEffect(()=>u(new Date().toLocaleDateString("sv-SE")),[]);const[f,d]=q.useState(null),h=q.useCallback(()=>d(null),[]);if(q.useEffect(()=>{if(f===null)return;const x=y=>{y.key==="Escape"&&h()},v=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",x),()=>{document.body.style.overflow=v,window.removeEventListener("keydown",x)}},[f,h]),c.items.length===0)return null;const p=f===null?void 0:c.items[f],g=p&&o?rp(p,o):void 0;return n.jsxs(Rl,{id:"event",name:c.title||Ye(i,"event",`${i.place.name} 소식`),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,count:"자세한 내용과 최신 소식은 원문에서 확인해 주세요.",children:[n.jsx("ul",{className:"grid gap-5 sm:grid-cols-2 lg:grid-cols-3",children:c.items.map((x,v)=>{const y=o?rp(x,o):void 0,z=Go(x,y),w=$o(x),N=y==="closed";return n.jsxs("li",{className:"w4-paper relative flex flex-col border-2",style:{backgroundColor:tl,borderColor:Ce,opacity:N?.6:1,boxShadow:N?"none":"3px 3px 0 color-mix(in oklab, currentcolor 12%, transparent)"},children:[n.jsx("button",{type:"button",onClick:()=>d(v),"aria-label":`${x.title} 자세히 보기`,className:"absolute inset-0 z-20 cursor-pointer"}),n.jsx("div",{className:x.imageUrl?"relative overflow-hidden border-b-2":"contents",style:x.imageUrl?{borderColor:Ce}:void 0,children:x.imageUrl?n.jsx("img",{src:x.imageUrl,alt:`${x.title} 안내 이미지`,loading:"lazy",decoding:"async",className:"aspect-4/3 w-full object-cover",style:N?{filter:"grayscale(1)"}:void 0}):null}),n.jsxs("div",{className:"flex flex-1 flex-col gap-2.5 p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[z&&n.jsx("span",{className:"border-2 px-2 py-0.5 text-[11px] font-bold tracking-[0.08em]",style:{transform:"rotate(-3deg)",backgroundColor:y==="open"&&z!=="공지"?We:tl,color:y==="open"&&z!=="공지"?ra:ul,borderColor:y==="open"&&z!=="공지"?We:Ce},children:z}),w&&n.jsx("span",{className:"text-[11px] font-semibold tabular-nums opacity-55",children:w})]}),n.jsx("h3",{className:"serif text-lg font-bold leading-snug",children:x.title}),x.summary&&n.jsx("p",{className:"line-clamp-2 text-[13px] leading-relaxed opacity-80",children:x.summary}),x.howTo&&n.jsx("p",{className:"border-2 border-dashed px-3 py-2 text-[12px] leading-relaxed",style:{borderColor:Ce},children:x.howTo}),x.body&&n.jsx("p",{className:`whitespace-pre-line text-[12px] leading-relaxed opacity-70 ${x.imageUrl?"line-clamp-3":"line-clamp-[9]"}`,children:x.body}),n.jsx("p",{className:"mt-auto pt-2 text-[12px] font-bold opacity-70",children:"자세히 보기 →"})]})]},`${x.title}-${v}`)})}),p&&n.jsx("div",{role:"dialog","aria-modal":"true","aria-label":p.title,className:"fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6",onClick:h,children:n.jsxs("article",{onClick:x=>x.stopPropagation(),className:"w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2",style:{backgroundColor:tl,borderColor:Ce,color:ul},children:[p.imageUrl&&n.jsx("img",{src:p.imageUrl,alt:`${p.title} 안내 이미지`,className:"max-h-[46vh] w-full object-contain",style:{backgroundColor:"color-mix(in oklab, currentcolor 6%, transparent)"}}),n.jsxs("div",{className:"space-y-4 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[Go(p,g)&&n.jsx("span",{className:"border-2 px-2.5 py-1 text-[11px] font-bold tracking-[0.08em]",style:{borderColor:Ce},children:Go(p,g)}),$o(p)&&n.jsx("span",{className:"text-[12px] font-semibold tabular-nums opacity-60",children:$o(p)})]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h3)] font-bold leading-snug",children:p.title}),p.summary&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.summary}),p.howTo&&n.jsx("p",{className:"border-2 border-dashed px-4 py-3 text-[length:var(--fs-sm)] leading-relaxed",style:{borderColor:Ce},children:p.howTo}),p.body&&n.jsx("p",{className:"whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.body}),n.jsxs("div",{className:"flex items-center justify-between gap-4 pt-2",children:[p.postUrl?n.jsx("a",{href:p.postUrl,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",children:"원문 보기 →"}):n.jsx("span",{}),n.jsxs("button",{type:"button",onClick:h,className:"tap inline-flex items-center gap-1.5 border-2 px-4 text-[length:var(--fs-sm)] font-bold",style:{borderColor:Ce},children:[n.jsx(pp,{className:"size-4"}),n.jsx("span",{children:"닫기"})]})]})]})]})})]})}const l1={songs:Dp,daily:Rp,people:Up,chronicle:Hp,postcard:Lp,quiz:Ly,itinerary:Ky,video:Wy,event:t1,story:Py};function a1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(i1,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(c1,{}),n.jsx(Tn,{})]})}function qp(){const i=le(),c=ft(i);return[{id:"intro",href:"#about",fallback:"소개"},{id:c.path,href:"#units",fallback:c.label},{id:"info",href:"#info",fallback:"이용 정보"},{id:"booking",href:"#booking",fallback:"예약 안내"},{id:"photos",href:"#gallery",fallback:"사진"},{id:"map",href:"#location",fallback:"오시는 길"},{id:"local",href:"#guide",fallback:"주변 정보"},{id:"faq",href:"#faq",fallback:"자주 묻는 질문"}].filter(o=>Kt(i,o.id)).map(o=>({label:Ye(i,o.id,o.fallback),href:o.href}))}const n1=24,s1="0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)";function i1(){const i=le(),c=qp(),[o,u]=q.useState(!1);q.useEffect(()=>{const d=()=>u(window.scrollY>n1);return d(),window.addEventListener("scroll",d,{passive:!0}),()=>window.removeEventListener("scroll",d)},[]);const f=o?"opacity-70 transition-opacity hover:opacity-100":"underline-offset-4 hover:underline";return n.jsx("header",{className:`fixed inset-x-0 top-0 z-40 w-full transition-colors duration-300 ${o?"border-line border-b backdrop-blur-md":""}`,style:o?{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"}:{color:"var(--tpl-bg, #ffffff)",textShadow:s1},children:n.jsxs("div",{className:"shell grid h-20 grid-cols-[1fr_auto] items-center gap-4 lg:grid-cols-[1fr_auto_1fr]",children:[n.jsx("a",{href:"#top",className:"min-w-0",children:n.jsx("span",{className:"serif block truncate",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.12em"},children:i.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center justify-center gap-10 lg:flex",children:c.map(d=>n.jsx("a",{href:d.href,className:`py-1 text-[length:var(--fs-sm)] font-light ${f}`,children:d.label},d.href))}),n.jsxs("div",{className:"flex items-center justify-end gap-2",children:[i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:`tap hidden items-center text-[length:var(--fs-sm)] font-light tabular-nums sm:inline-flex ${f}`,children:i.place.phone}),c.length>0&&n.jsxs("details",{className:"lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center justify-center px-2 text-[length:var(--fs-sm)] font-light marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:"메뉴"}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line absolute inset-x-0 top-full border-b border-t",style:{backgroundColor:"var(--tpl-surface, #fff)",color:"var(--tpl-text)",textShadow:"none"},children:n.jsx("ul",{className:"divide-line shell divide-y",children:c.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center text-[length:var(--fs-sm)] font-light",children:d.label})},d.href))})})]})]})]})})}function c1(){var x,v,y,z;const i=le(),{place:c,site:o,narrative:u}=i,f=qp(),d=c.roadAddress??c.address,h=Ze(i)[0],p=wn(i),g=u.about[0]??u.summary;return n.jsx("footer",{className:"border-line paper w-full border-t pb-28 md:pb-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)",paddingTop:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid gap-10 md:grid-cols-3 md:gap-12",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"serif",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.1em"},children:c.name}),g&&n.jsx("p",{className:"text-muted measure mt-4 text-[length:var(--fs-sm)] leading-loose",children:g})]}),n.jsxs("dl",{className:"min-w-0 space-y-5",children:[d&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"주소"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:d})]}),c.phone&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"전화"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light tabular-nums",children:n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-4 hover:underline",children:c.phone})})]}),c.email&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"이메일"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-4 hover:underline",children:c.email})})]}),h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"예약"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"underline-offset-4 hover:underline",children:ks(h)})})]})]}),f.length>0&&n.jsx("nav",{"aria-label":"사이트 메뉴",className:"min-w-0",children:n.jsx("ul",{className:"space-y-3",children:f.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.href,className:"text-muted text-[length:var(--fs-sm)] font-light underline-offset-4 hover:underline",children:w.label})},w.href))})})]}),n.jsx("hr",{className:"border-line mt-12 border-t"}),p.length>0&&n.jsx("ul",{className:"mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted text-[length:var(--fs-xs)] font-light underline-offset-4 hover:underline",children:ks(w)})},w.url))}),n.jsxs("div",{className:"text-muted mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-center text-[length:var(--fs-xs)] font-light",children:[n.jsxs("span",{children:["상호 ",c.name]}),((x=c.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표 ",c.legal.representative]}),((v=c.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호 ",c.legal.businessRegistrationNumber]}),((y=c.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고 ",c.legal.mailOrderNumber]}),((z=c.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호"," ",c.legal.licenseNumber]}),n.jsxs("span",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}const r1=` +.oasi-page main > section:not([data-oasi]) { + background-color: transparent !important; + background-image: none !important; + border-color: transparent !important; + color: var(--tpl-text) !important; + padding-block: var(--oasi-gap) 0 !important; +} +.oasi-page main > section:not([data-oasi]) .shell { + max-width: none; + padding-inline: 0; +} +.oasi-page main > section#about > .shell > .grid { + display: block; +} +.oasi-page main > section#about > .shell > .grid > * + * { + margin-top: 2.5rem; +} +.oasi-page main > section#summary dl { + grid-template-columns: minmax(0, 1fr); +} + +/* + * 붙여넣기 아이템 카드 — 소식 · 영상 · 추천 일정 (2026-09-04, 사장님: "1,2,3안 다 맞추세요") + * + * ★ 이 카드들은 .panel 이 아니라 제 인라인 style 로 면을 그린다(items/common.tsx 의 + * ITEM_CARD·ITEM_BORDER). 그래서 위 .panel 규칙이 안 닿았고, 섹션 띠를 다 지운 3안에서 + * **소식 카드만 갱지 결에 2px 테두리를 두른 상자로 혼자 서 있었다.** + * ★ w4-paper 의 결은 1안(레트로 갱지)의 것이다. 여기서는 결을 지우고 테두리를 실선 한 겹으로 + * 낮춘다. 상자를 아예 없애지는 않는다 — 전단 여섯 장이 경계 없이 붙으면 어디까지가 한 장인지 + * 안 보인다. 면은 빼고 테두리만 남기는 게 이 안의 타협점이다. + * ★ 배경은 transparent 로 두지 않는다. 사진 없는 공지 카드가 종이에 바로 얹히면 + * 본문과 구분이 사라진다 — 아주 옅은 틴트 한 겹만 남긴다. + * ★ 영상은 뺐다 (2026-09-04, 사장님: "영상 border 안 예쁘다") + * 영상 카드는 상자가 아니라 **표지 사진 한 장**이고, 쇼츠라 9:16 세로다. li 에 틴트를 깔면 + * 영상보다 넓은 회색 판이 뒤에 깔려 액자처럼 보인다. 사진은 제 테두리가 곧 경계다. + */ +.oasi-page section#event li, +.oasi-page section#itinerary article { + background-image: none !important; + background-color: color-mix(in oklab, currentcolor 3%, transparent) !important; + border-width: 1px !important; + box-shadow: none !important; +} + +.oasi-page .panel, +.oasi-page .panel-sunken { + background-color: transparent; + border-width: 0; + border-radius: 0; + box-shadow: none; +} + +.oasi-page main > section#faq > .shell > div:last-of-type { + row-gap: 0; + column-gap: 2.5rem; +} +.oasi-page main > section#faq details { + margin-block: 0; + border-top: 1px solid var(--site-line); +} +.oasi-page main > section#faq summary { + padding-inline: 0; +} +.oasi-page main > section#faq summary > span:first-child { + width: 1.125rem; +} +.oasi-page main > section#faq summary + div { + border-top-width: 0; + padding-inline: 1.875rem 0; + padding-top: 0.5rem; +} + +.oasi-page main > section#info dl.panel > div { + padding-inline: 0; +} +.oasi-page main > section#info dl > div.panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} + +.oasi-page main > section#location .panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):first-child { + padding-left: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):last-child { + padding-right: 0; +} + +.oasi-page main > section#weather .panel { + padding-inline: 0; +} + +.oasi-page main > section#guide .panel > span:last-child { + padding-inline: 0; +} +@media (min-width: 40rem) { + .oasi-page main > section#festival .panel > span:last-child { + padding-inline: 0; + } +} +`,o1={"--oasi-gap":"clamp(3.5rem, 7vw, var(--tpl-section-space, 4rem))",backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"};function u1({children:i}){var v,y,z,w;const c=le(),{place:o}=c,u=ft(c),f=Ze(c)[0],d=wn(c),h=[{label:"처음",href:"#top",show:!0},{label:"소개",href:"#about",show:Kt(c,"intro")},{label:u.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(N=>N.show),p=[...Oa(c).map(N=>`${N.label} ${N.value}`),...c.theme.sections.filter(N=>{var A;return N.enabled&&((A=N.name)==null?void 0:A.trim())}).map(N=>N.name.trim())].slice(0,3),g=c.narrative.tagline??c.narrative.heroSubline,x=o.roadAddress??o.address;return n.jsxs("div",{className:"oasi-page flex min-h-screen w-full flex-col",style:o1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:r1}}),n.jsxs("div",{className:"mx-auto flex w-full max-w-[72rem] flex-col gap-10 px-5 pb-16 pt-10 lg:flex-row lg:gap-14 lg:px-10 lg:pb-24 lg:pt-16",children:[n.jsxs("header",{className:"lg:sticky lg:top-12 lg:h-fit lg:w-[18.75rem] lg:shrink-0",children:[n.jsxs("a",{href:"#top",className:"block",children:[n.jsx("span",{className:"serif block leading-none",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.14em"},children:o.name}),o.englishName&&n.jsx("span",{className:"text-muted mt-3 block text-[length:var(--fs-xs)] tracking-[0.24em]",children:o.englishName})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-12 lg:mt-24",children:n.jsx("ul",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 lg:block",children:h.map((N,A)=>n.jsx("li",{children:n.jsx("a",{href:N.href,"aria-current":A===0?"true":void 0,className:`tap inline-flex items-center text-[length:var(--fs-sm)] transition-opacity hover:opacity-100 ${A===0?"":"text-muted"}`,children:N.label})},N.label))})}),f&&n.jsx("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(f)})]}),n.jsxs("div",{className:"flex min-w-0 flex-1 flex-col",children:[(p.length>0||g)&&n.jsxs("div",{className:"border-line flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6",children:[p.length>0&&n.jsx("ul",{className:"text-muted flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)]",children:p.map((N,A)=>n.jsxs("li",{className:"flex items-center gap-4",children:[A>0&&n.jsx("span",{"aria-hidden":!0,className:"opacity-40",children:"|"}),n.jsx("span",{children:N})]},N))}),g&&n.jsx("p",{className:"text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right",children:g})]}),i]})]}),n.jsx("footer",{className:"border-line mt-auto w-full border-t",children:n.jsxs("div",{className:"mx-auto grid w-full max-w-[72rem] grid-cols-1 gap-8 px-5 pb-28 pt-10 text-[length:var(--fs-xs)] leading-[1.9] sm:grid-cols-3 lg:px-10 lg:pb-14",children:[n.jsxs("p",{children:["[",o.name,"]"]}),d.length>0?n.jsx("ul",{className:"space-y-1",children:d.map(N=>n.jsx("li",{children:n.jsx("a",{href:N.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-[5px]",children:ks(N)})},N.url))}):n.jsx("span",{}),n.jsxs("div",{className:"text-muted space-y-1 sm:text-right",children:[x&&n.jsx("p",{children:x}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-[5px]",children:o.phone})}),o.email&&n.jsx("p",{children:o.email}),((v=o.legal)==null?void 0:v.representative)&&n.jsxs("p",{children:["대표 ",o.legal.representative]}),((y=o.legal)==null?void 0:y.businessRegistrationNumber)&&n.jsxs("p",{children:["사업자등록번호 ",o.legal.businessRegistrationNumber]}),((z=o.legal)==null?void 0:z.mailOrderNumber)&&n.jsxs("p",{children:["통신판매업신고 ",o.legal.mailOrderNumber]}),((w=o.legal)==null?void 0:w.licenseNumber)&&n.jsxs("p",{children:[o.legal.licenseLabel??"인허가번호"," ",o.legal.licenseNumber]}),n.jsxs("p",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:c.site.updatedAt,children:qs(c.site.updatedAt)})]})]})]})}),n.jsx(Tn,{})]})}function f1({children:i}){var z,w,N,A;const c=le(),{place:o,narrative:u,site:f}=c,d=ft(c),h=En(c),p=Oa(c),g=Ze(c)[0],x=c.links.filter(D=>D.confirmed),v=o.roadAddress??o.address,y=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:d.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변 정보",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(D=>D.show);return n.jsxs("div",{className:"flex min-h-screen w-full flex-col lg:flex-row",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("aside",{className:"w-full shrink-0 px-6 pb-8 pt-8 lg:sticky lg:top-0 lg:h-screen lg:w-[260px] lg:overflow-y-auto lg:px-7 lg:py-10",style:{backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"flex h-full min-w-0 flex-col",children:[n.jsx("a",{href:"#top",className:"block min-w-0",children:n.jsx("h1",{className:"serif min-w-0 break-keep font-black",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.12em",lineHeight:1.25},children:o.name})}),(u.tagline??u.heroSubline)&&n.jsx("p",{className:"text-muted mt-3 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:u.tagline??u.heroSubline}),(h||p.length>0)&&n.jsxs("dl",{className:"mt-7 space-y-2.5",children:[h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:h})]}),p.map(D=>n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:D.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:D.value})]},D.label))]}),g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-5 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg)"},children:ht(g)}),o.phone&&n.jsxs("a",{href:`tel:${o.phone}`,className:"tap mt-2 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--tpl-surface-alt)",color:"var(--tpl-text)"},children:["전화 ",o.phone]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-10",children:n.jsx("ul",{className:"space-y-0.5",children:y.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.href,className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-sm)] transition-colors hover:text-current",children:D.label})},D.label))})}),x.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"mt-12",children:n.jsx("ul",{className:"space-y-0.5",children:x.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-xs)] underline-offset-4 transition-colors hover:text-current hover:underline",children:ml(D)})},D.url))})}),n.jsxs("div",{className:"text-muted mt-14 space-y-1 pb-2 text-[length:var(--fs-xs)] lg:mt-auto lg:pt-14",children:[v&&n.jsx("p",{className:"break-keep",children:v}),o.phone&&n.jsx("p",{children:o.phone}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline-offset-2 hover:underline",children:o.email})}),n.jsxs("div",{className:"flex flex-wrap gap-x-2 gap-y-0.5 pt-3",children:[n.jsxs("span",{children:["상호: ",o.name]}),((z=o.legal)==null?void 0:z.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((w=o.legal)==null?void 0:w.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((N=o.legal)==null?void 0:N.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((A=o.legal)==null?void 0:A.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline pt-1",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:f.updatedAt,children:qs(f.updatedAt)})]})]})]})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col pb-24 lg:pb-0",children:i}),n.jsx(Tn,{})]})}const d1=` +.pastel-page > main > section:not([data-pastel]) { + border-color: transparent; + background-image: none; + color: var(--pastel-ink) !important; +} +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+1) { background-color: var(--pastel-1) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+2) { background-color: var(--pastel-soft) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+3) { background-color: var(--pastel-2) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n) { background-color: var(--pastel-3) !important; } +`,m1={"--pastel-1":"var(--tpl-card)","--pastel-2":"var(--tpl-accent)","--pastel-3":"var(--tpl-secondary)","--pastel-soft":"var(--tpl-bg)","--pastel-ink":"var(--tpl-text)","--section-space":"clamp(2.75rem, 8vw, calc(var(--tpl-section-space, 4rem) * 1.4))",backgroundColor:"var(--pastel-soft)",color:"var(--pastel-ink)"};function h1({children:i}){const c=le(),o=ft(c),u=wn(c),f=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:o.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:c.faqs.length>0}].filter(d=>d.show);return n.jsxs("div",{className:"pastel-page flex min-h-screen w-full flex-col",style:m1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:d1}}),n.jsx("header",{className:"sticky top-0 z-40 w-full",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsx("a",{href:"#top",className:"flex min-w-0 items-center gap-2",children:n.jsx("span",{className:"serif truncate italic",style:{fontSize:"var(--fs-lead)",fontWeight:800,display:"inline-block",transform:"rotate(-1.5deg)"},children:c.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-8 lg:flex",children:f.map(d=>n.jsx("a",{href:d.href,className:"py-1 text-[length:var(--fs-xs)] font-bold tracking-[0.2em] opacity-70 transition-opacity hover:opacity-100",children:d.label},d.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1",children:[c.place.phone&&n.jsx("a",{href:`tel:${c.place.phone}`,"aria-label":"전화",className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-1)"},children:n.jsx(_t,{className:"size-4"})}),u.map(d=>n.jsx("a",{href:d.url,target:"_blank",rel:"noopener noreferrer","aria-label":ml(d),className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-2)"},children:/instagram/i.test(d.url)?n.jsx(Ub,{className:"size-4"}):n.jsx(Io,{className:"size-4"})},d.url)),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-full marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"absolute right-0 top-[calc(100%+0.5rem)] z-50 w-48 overflow-hidden rounded-2xl",style:{backgroundColor:"var(--tpl-surface, #fff)",boxShadow:"0 10px 30px rgb(0 0 0 / 0.12)"},children:n.jsx("ul",{children:f.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-bold tracking-[0.08em]",children:d.label})},d.label))})})]})]})]})}),i,n.jsx("div",{style:{"--tpl-inverse":"var(--pastel-3)","--tpl-bg":"var(--pastel-ink)"},children:n.jsx(Op,{})}),n.jsx(Tn,{})]})}function p1({children:i}){var x,v,y,z;const c=le(),{place:o,site:u}=c,f=Ep(),d=x1(f),h=Ze(c)[0],p=c.links.filter(w=>w.confirmed),g=o.roadAddress??o.address;return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("header",{className:"sticky top-0 z-40 w-full border-b-2 backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-bg) 90%, transparent)",borderColor:"var(--tpl-text)"},children:n.jsxs("div",{className:"flex h-14 items-center justify-between gap-4 px-4 sm:px-6 xl:px-8",children:[n.jsx("a",{href:"#top",className:"serif min-w-0 truncate text-[length:var(--fs-lead)] font-extrabold",children:o.name}),n.jsxs("div",{className:"flex shrink-0 items-center gap-4 text-[length:var(--fs-sm)]",children:[o.phone&&n.jsx("a",{href:`tel:${o.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:o.phone}),h&&n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center font-bold underline underline-offset-4",children:ht(h)})]})]})}),n.jsxs("div",{className:"flex w-full flex-1",children:[f.length>0&&n.jsx("aside",{className:"border-line sticky top-14 hidden h-[calc(100vh-3.5rem)] w-44 shrink-0 self-start border-r xl:block",style:{backgroundImage:Os},children:n.jsx("nav",{"aria-label":"차례",className:"flex h-full flex-col justify-center gap-0.5 py-8 pl-6 pr-3",children:f.map(w=>{const N=w.anchor===d;return n.jsxs("a",{href:`#${w.anchor}`,"aria-current":N?"true":void 0,className:"flex items-baseline gap-2 py-2 text-[length:var(--fs-sm)] leading-tight motion-safe:transition-opacity",style:{opacity:N?1:.35},children:[n.jsx("span",{className:"shrink-0 text-[length:var(--fs-xs)] font-bold tabular-nums",children:w.no}),n.jsx("span",{className:"truncate",style:{fontWeight:N?700:400},children:w.label})]},w.anchor)})})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col",children:i})]}),n.jsx("footer",{className:"w-full pb-28 pt-12 md:pb-16",style:{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os},children:n.jsxs("div",{className:"shell",children:[n.jsx("div",{"aria-hidden":!0,className:"h-0.5 w-full bg-current opacity-70"}),n.jsxs("div",{className:"grid grid-cols-1 gap-8 pt-8 md:grid-cols-12",children:[n.jsxs("div",{className:"md:col-span-5",children:[n.jsx("p",{className:"serif",style:{fontSize:"clamp(1.5rem, 4vw, 2.5rem)",fontWeight:800,lineHeight:1.05},children:o.name}),o.englishName&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-xs)] uppercase tracking-[0.18em] opacity-55",children:o.englishName})]}),n.jsxs("address",{className:"space-y-2 font-serif text-[length:var(--fs-sm)] not-italic opacity-80 md:col-span-4",children:[g&&n.jsx("p",{children:g}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-4",children:o.phone})}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline underline-offset-4",children:o.email})})]}),p.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"md:col-span-3",children:n.jsx("ul",{className:"space-y-1.5 text-[length:var(--fs-sm)]",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:ml(w)})},w.url))})})]}),n.jsxs("div",{className:"mt-10 flex flex-col gap-2 border-t border-current/20 pt-5 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",o.name]}),((x=o.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((v=o.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((y=o.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((z=o.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:u.updatedAt,children:qs(u.updatedAt)})]})]})]})}),n.jsx(Tn,{})]})}function x1(i){const c=i.map(f=>f.anchor).join(","),[o,u]=q.useState("");return q.useEffect(()=>{if(typeof IntersectionObserver>"u")return;const f=c.split(",").map(h=>document.getElementById(h)).filter(h=>h!==null);if(f.length===0)return;const d=new IntersectionObserver(h=>{const p=h.find(g=>g.isIntersecting);p&&u(p.target.id)},{rootMargin:"-45% 0px -50% 0px"});return f.forEach(h=>d.observe(h)),()=>d.disconnect()},[c]),o}function g1(){const i=le(),c=i.place.category===Ma.LODGING,o={intro:ny,info:sy,rooms:Lo,menu:Lo,programs:Lo,booking:c?Fh:xy,space:Ny,inquiry:Sy,exhibition:zy,photos:Dy,festival:Ty,local:Ay,weather:Oy,map:ap,faq:Ry,...l1},u=new Set;return n.jsxs("main",{className:"w-full flex-1",children:[n.jsx(xv,{}),!Kt(i,"intro")&&n.jsx(Hh,{}),i.theme.sections.filter(f=>f.enabled&&f.id!=="hero").map(f=>{const d=o[f.id];if(!d)return null;const h=d.name;return u.has(h)?null:(u.add(h),n.jsxs("div",{"data-editor-id":f.id,style:{display:"contents"},children:[n.jsx(d,{}),f.id==="intro"&&n.jsx(Hh,{})]},f.id))}),!Kt(i,"map")&&n.jsx(ap,{}),c&&!F0(i,"booking")&&n.jsx(Fh,{})]})}function Wo({payload:i}){const c=zp(i.theme.templateId),o=c==="reservation"?a1:c==="oasi"?u1:c==="studio"?f1:c==="pastel"?h1:c==="editorial"?p1:b1;return n.jsx(n0,{payload:i,children:n.jsx(o,{children:n.jsx(g1,{})})})}function b1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(I0,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(Op,{}),n.jsx(Tn,{})]})}function v1(i){const{colors:c,look:o}=i.theme,u=T0(c),f={"--tpl-primary":c.primary,"--tpl-secondary":c.secondary,"--tpl-bg":c.bg,"--tpl-card":c.card,"--tpl-text":c.text,"--tpl-accent":c.accent,"--tpl-surface":u.surface,"--tpl-surface-alt":u.surfaceAlt,"--tpl-inverse":u.inverse,"--tpl-border":u.border};if(o){const d=p=>p&&!/[<>{};]/.test(p)?p:void 0,h=[["--tpl-font-heading",d(o.fontHeading)],["--tpl-font-body",d(o.fontBody)],["--tpl-heading-tracking",d(o.headingTracking)],["--tpl-heading-weight",d(o.headingWeight)],["--tpl-section-space",d(o.sectionSpace)],["--tpl-border-width",d(o.borderWidth)],["--tpl-radius",d(o.radius)],["--tpl-shadow",d(o.shadow)],["--tpl-texture",d(o.texture)]];for(const[p,g]of h)g&&(f[p]=g)}return f}const y1=[[/Noto Sans KR/i,"family=Noto+Sans+KR:wght@300..900"],[/Noto Serif KR/i,"family=Noto+Serif+KR:wght@300..700"],[/Gugi/i,"family=Gugi"],[/Gowun Batang/i,"family=Gowun+Batang:wght@400;700"],[/Nanum Pen Script/i,"family=Nanum+Pen+Script"]];function j1(i){var u,f;const c=["Noto Sans KR","Noto Serif KR",((u=i.theme.look)==null?void 0:u.fontHeading)??"",((f=i.theme.look)==null?void 0:f.fontBody)??""].join(" ");return`https://fonts.googleapis.com/css2?${y1.filter(([d])=>d.test(c)).map(([,d])=>d).join("&")}&display=swap`}const oc=document.getElementById("root"),op=window.__SITE_PAYLOAD__;async function N1(i){const c=(()=>{try{return localStorage.getItem("o2o-web4ai.accessToken")}catch{return null}})(),o=await fetch(`/v1/place/${i}/site/preview`,{headers:c?{Authorization:`Bearer ${c}`}:{}});if(!o.ok)throw new Error(`HTTP ${o.status}`);const u=await o.json();for(const[d,h]of Object.entries(v1(u)))document.documentElement.style.setProperty(d,h);const f=document.createElement("link");f.rel="stylesheet",f.href=j1(u),document.head.appendChild(f),await new Promise(d=>{f.addEventListener("load",()=>d(),{once:!0}),f.addEventListener("error",()=>d(),{once:!0}),setTimeout(d,2500)});try{await document.fonts.ready}catch{}Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:u})}))}const up=new URLSearchParams(window.location.search).get("placeId");op?Qo.hydrateRoot(oc,n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:op})})):up?N1(up).catch(i=>{oc.textContent=`미리보기를 불러오지 못했습니다 — ${i instanceof Error?i.message:String(i)}`}):tb(async()=>{const{MOONLIGHT_STAY_PAYLOAD:i}=await import("./moonlight-stay-B0tfisrz.js");return{MOONLIGHT_STAY_PAYLOAD:i}},[]).then(({MOONLIGHT_STAY_PAYLOAD:i})=>{Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:i})}))});export{_h as F,bt as L,Ma as P,f0 as S,S1 as a}; diff --git a/solution/site/scripts/mockup/vendor/index-DQreaXOf.css b/solution/site/scripts/mockup/vendor/index-DQreaXOf.css new file mode 100644 index 0000000..011b41a --- /dev/null +++ b/solution/site/scripts/mockup/vendor/index-DQreaXOf.css @@ -0,0 +1 @@ +#w4d-mini{display:inline-flex;align-items:center;gap:2px;margin-right:6px;padding-right:8px;border-right:1px solid var(--tpl-border, #bcb49e)}#w4d-mini button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;color:var(--tpl-text, #1b1a15);background:transparent;border:0;border-radius:var(--tpl-radius, 0);cursor:pointer;opacity:.75}#w4d-mini button:hover{opacity:1;background:#1b1a150f}#w4d-mini svg{width:16px;height:16px}#w4d-now{max-width:150px;overflow:hidden;font-size:11.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis;color:var(--tpl-accent, #bf2f1b);padding-right:2px}#w4d-mini[data-playing="0"] #w4d-now{opacity:.55;font-weight:400}@media(max-width:860px){#w4d-now{display:none}}#w4d-tape{display:inline-flex;align-items:center;padding:0 4px 0 2px;color:var(--tpl-text, #1b1a15);opacity:.8}#w4d-tape svg{width:20px;height:20px}#w4d-mini[data-playing="1"] #w4d-tape{color:var(--tpl-accent, #bf2f1b);opacity:1}#w4d-mini[data-playing="1"] .w4d-reel{transform-box:fill-box;transform-origin:center;animation:w4d-reel 2.6s linear infinite}@keyframes w4d-reel{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.w4d-reel{animation:none!important}}#w4d-mini button[data-playing="1"]{color:var(--tpl-accent, #bf2f1b);opacity:1}@media(max-width:400px){#w4d-mini{margin-right:2px;padding-right:4px}#w4d-mini button{width:27px;height:27px}}#w4d-panel{position:fixed;z-index:45;width:344px;max-width:calc(100vw - 16px);max-height:min(64vh,520px);display:flex;flex-direction:column;overflow:hidden;font-family:var(--tpl-font-body, serif);color:var(--tpl-text, #1b1a15);background:var(--tpl-card, #efe7d3);border:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);box-shadow:var(--tpl-shadow, 4px 4px 0 rgb(27 26 21 / .16))}#w4d-panel[hidden]{display:none}@media(max-width:640px){#w4d-panel{width:auto}}#w4d-panel .w4d-head{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding:11px 8px 11px 14px;border-bottom:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15)}.w4d-head-t{font-size:12px;font-weight:700;letter-spacing:.06em}.w4d-head-n{flex:1 1 auto;font-size:11px;opacity:.5}#w4d-panel .w4d-head button{display:inline-flex;width:28px;height:28px;flex:0 0 auto;align-items:center;justify-content:center;color:inherit;background:transparent;border:0;cursor:pointer;opacity:.6}#w4d-panel .w4d-head button:hover{opacity:1}#w4d-panel .w4d-head svg{width:14px;height:14px}#w4d-panel ol{flex:1 1 auto;margin:0;padding:4px 0;overflow-y:auto;list-style:none;-webkit-overflow-scrolling:touch}.w4d-item{display:flex;width:100%;min-height:52px;align-items:center;gap:11px;padding:8px 14px;color:inherit;background:transparent;border:0;font:inherit;text-align:left;cursor:pointer}.w4d-item+.w4d-item{border-top:1px dashed var(--tpl-border, #bcb49e)}.w4d-item:hover{background:#1b1a150d}.w4d-item[aria-current=true]{background:#1b1a150f}.w4d-item[aria-current=true] .w4d-t{color:var(--tpl-accent, #bf2f1b)}.w4d-mark{position:relative;flex:0 0 26px;height:26px}.w4d-disc-sm{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%;background:radial-gradient(circle at 50% 50%,var(--w4d-lbl, #bf2f1b) 0 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,rgb(255 255 255 / .12) 0 1px,transparent 1px 3px),#14110f}.w4d-disc-sm:after{content:"";position:absolute;top:45%;right:45%;bottom:45%;left:45%;border-radius:50%;background:var(--tpl-card, #efe7d3)}.w4d-eq{position:absolute;top:0;right:0;bottom:0;left:0;display:none;align-items:flex-end;justify-content:center;gap:3px;padding-bottom:4px}.w4d-eq i{width:3px;height:6px;background:var(--tpl-accent, #bf2f1b);animation:w4d-eq .9s ease-in-out infinite}.w4d-eq i:nth-child(2){animation-delay:.18s}.w4d-eq i:nth-child(3){animation-delay:.36s}@keyframes w4d-eq{0%,to{height:5px}50%{height:17px}}.w4d-item[data-playing="1"] .w4d-disc-sm{display:none}.w4d-item[data-playing="1"] .w4d-eq{display:flex}@media(prefers-reduced-motion:reduce){.w4d-eq i{animation:none;height:11px}}.w4d-info{min-width:0;flex:1 1 auto}.w4d-t{display:block;overflow:hidden;font-size:13.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis}.w4d-sub{display:block;margin-top:2px;overflow:hidden;font-size:11px;white-space:nowrap;text-overflow:ellipsis;opacity:.55}.w4d-time{flex:0 0 auto;font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}#w4d-lyrics-box{padding:10px 12px 14px;border-top:1px dashed var(--tpl-border, #bcb49e);white-space:pre-line;font-size:12px;line-height:1.9;overflow-y:auto}#w4d-lyrics-box[hidden]{display:none}.w4-sky{position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:none;overflow:hidden}.w4-sky[data-mood=비]:before{content:"";position:absolute;top:-20%;right:-10%;bottom:-20%;left:-10%;background-image:repeating-linear-gradient(102deg,transparent 0 9px,currentcolor 9px 10px,transparent 10px 22px);opacity:.13;animation:w4-rain .9s linear infinite}@keyframes w4-rain{to{transform:translate3d(-22px,108px,0)}}.w4-sky[data-mood=맑음]:before{content:"";position:absolute;right:6%;top:-40%;width:26rem;aspect-ratio:1;background-image:conic-gradient(from 0deg,currentcolor 0 2deg,transparent 2deg 30deg);opacity:.07;animation:w4-spin 70s linear infinite}.w4-sky[data-mood=맑음]:after{content:"";position:absolute;right:10%;top:-14%;width:15rem;aspect-ratio:1;border-radius:50%;background-image:radial-gradient(circle,currentcolor 0%,transparent 62%);opacity:.11;animation:w4-breathe 9s ease-in-out infinite}@keyframes w4-spin{to{transform:rotate(360deg)}}@keyframes w4-breathe{50%{opacity:.2}}.w4-sky[data-mood=흐림]:before,.w4-sky[data-mood=흐림]:after{content:"";position:absolute;top:-30%;right:-60%;bottom:-30%;left:-60%;background-image:radial-gradient(ellipse 22% 46% at 18% 42%,currentcolor 0%,transparent 70%),radial-gradient(ellipse 30% 40% at 62% 30%,currentcolor 0%,transparent 72%);opacity:.08;animation:w4-drift 44s linear infinite}.w4-sky[data-mood=흐림]:after{opacity:.05;animation-duration:78s;animation-direction:reverse}@keyframes w4-drift{to{transform:translate3d(28%,0,0)}}.w4-sky[data-mood=눈]:before{content:"";position:absolute;top:-20%;right:0;bottom:-20%;left:0;background-image:radial-gradient(circle at 12% 10%,currentcolor 1.4px,transparent 1.8px),radial-gradient(circle at 47% 34%,currentcolor 1.1px,transparent 1.5px),radial-gradient(circle at 78% 18%,currentcolor 1.6px,transparent 2px);background-size:9rem 9rem;opacity:.22;animation:w4-snow 14s linear infinite}@keyframes w4-snow{to{transform:translate3d(-2rem,9rem,0)}}@media(prefers-reduced-motion:reduce){.w4-sky:before,.w4-sky:after{animation:none!important}}.w4-paper{background-image:repeating-linear-gradient(0deg,color-mix(in oklab,currentcolor 4%,transparent) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,color-mix(in oklab,currentcolor 3%,transparent) 0 1px,transparent 1px 4px)}.w4-disc{background:radial-gradient(circle at 50% 50%,transparent 0 15.5%,var(--lbl) 15.5% 33%,transparent 33%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.4px,var(--w4-vinyl) 1.4px 3px),var(--w4-vinyl);box-shadow:inset 0 0 40px #0000008c}.w4-disc-sheen{background:conic-gradient(from 210deg,rgb(255 255 255 / 12%),transparent 22%,transparent 70%,rgb(255 255 255 / 7%))}.w4-spin{animation:w4-rev 2.2s linear infinite}@keyframes w4-rev{to{transform:rotate(360deg)}}.w4-disc-mini{background:radial-gradient(circle at 50% 50%,transparent 0 14%,var(--lbl) 14% 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.2px,var(--w4-vinyl) 1.2px 2.6px),var(--w4-vinyl);box-shadow:0 3px 10px #00000059}.w4-perf{background:repeating-linear-gradient(90deg,transparent 0 8px,var(--tear) 8px 9px)}.w4-dash{border-top:1px dashed currentcolor}.w4-film-perf{background:repeating-linear-gradient(90deg,currentcolor 0 9px,transparent 9px 21px)}.w4-flip{perspective:900px}.w4-flip-inner{transform-style:preserve-3d;transition:transform .5s}.w4-flip-on .w4-flip-inner{transform:rotateY(180deg)}.w4-flip-face{backface-visibility:hidden}.w4-flip-back{transform:rotateY(180deg)}.w4-scroll{overflow-x:auto;scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch}.w4-scroll::-webkit-scrollbar{display:none}.w4-scroll[data-slider=on]{overflow-x:hidden;scroll-snap-type:none}.w4-scroll[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}@media(prefers-reduced-motion:reduce){.w4-spin{animation:none}.w4-flip-inner{transition:none}}@media print{.w4-flip,.w4-flip-inner{height:auto!important;transform:none!important}.w4-flip-face{position:static!important;backface-visibility:visible!important;transform:none!important}}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--font-serif:"Noto Serif KR", "Batang", "Times New Roman", serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-600:oklch(59.6% .145 163.225);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-950:oklch(14.1% .005 285.823);--color-stone-600:oklch(44.4% .011 73.639);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--leading-loose:2;--radius-sm:calc(var(--tpl-radius,.75rem) * .4);--radius-md:calc(var(--tpl-radius,.75rem) * .7);--radius-lg:var(--tpl-radius,.75rem);--radius-xl:calc(var(--tpl-radius,.75rem) * 1.35);--radius-2xl:calc(var(--tpl-radius,.75rem) * 1.7);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--default-mono-font-family:var(--font-mono);--color-brand:var(--tpl-primary);--color-surface:var(--tpl-bg);--color-surface-alt:var(--tpl-card);--color-ink:var(--tpl-text);--color-accent:var(--tpl-accent);--color-line:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-line:color-mix(in oklab, currentColor 14%, transparent)}}:root,:host{--color-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-muted:color-mix(in oklab, currentColor 62%, transparent)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-2{inset:calc(var(--spacing) * 2)}.inset-x-0{inset-inline:0}.inset-y-\[3px\]{inset-block:3px}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(100\%\+0\.5rem\)\]{top:calc(100% + .5rem)}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.-right-\[7px\]{right:-7px}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-2\.5{bottom:calc(var(--spacing) * 2.5)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-5{bottom:calc(var(--spacing) * 5)}.-left-\[7px\]{left:-7px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-full{left:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.col-span-12{grid-column:span 12/span 12}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-3{margin-block:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mt-auto{margin-top:auto}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-7{margin-bottom:calc(var(--spacing) * 7)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-3{margin-left:calc(var(--spacing) * -3)}.-ml-10{margin-left:calc(var(--spacing) * -10)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-\[9\]{-webkit-line-clamp:9;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.table{display:table}.aspect-3\/2{aspect-ratio:3/2}.aspect-3\/4{aspect-ratio:3/4}.aspect-4\/3{aspect-ratio:4/3}.aspect-16\/9{aspect-ratio:16/9}.aspect-16\/10{aspect-ratio:16/10}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[16\/9\]{aspect-ratio:16/9}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-\[17px\]{width:17px;height:17px}.size-\[54px\]{width:54px;height:54px}.size-\[76px\]{width:76px;height:76px}.size-\[132px\]{width:132px;height:132px}.size-\[220px\]{width:220px;height:220px}.size-full{width:100%;height:100%}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-9{height:calc(var(--spacing) * 9)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-48{height:calc(var(--spacing) * 48)}.h-\[1em\]{height:1em}.h-\[6px\]{height:6px}.h-\[22px\]{height:22px}.h-\[56px\]{height:56px}.h-\[260px\]{height:260px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[clamp\(15rem\,24vw\,21rem\)\]{height:clamp(15rem,24vw,21rem)}.h-\[clamp\(17rem\,27vw\,24rem\)\]{height:clamp(17rem,27vw,24rem)}.h-auto{height:auto}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[26rem\]{max-height:26rem}.max-h-\[46vh\]{max-height:46vh}.max-h-\[72vh\]{max-height:72vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[88vh\]{max-height:88vh}.max-h-full{max-height:100%}.min-h-\[2rem\]{min-height:2rem}.min-h-\[16rem\]{min-height:16rem}.min-h-\[420px\]{min-height:420px}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-\[3px\]{width:3px}.w-\[44px\]{width:44px}.w-\[62px\]{width:62px}.w-\[110px\]{width:110px}.w-\[168px\]{width:168px}.w-\[210px\]{width:210px}.w-\[228px\]{width:228px}.w-\[236px\]{width:236px}.w-\[254px\]{width:254px}.w-\[262px\]{width:262px}.w-\[280px\]{width:280px}.w-\[304px\]{width:304px}.w-\[320px\]{width:320px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[34rem\]{max-width:34rem}.max-w-\[72rem\]{max-width:72rem}.max-w-\[880px\]{max-width:880px}.max-w-full{max-width:100%}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-\[22px\]{min-width:22px}.min-w-\[30rem\]{min-width:30rem}.min-w-\[34rem\]{min-width:34rem}.flex-1{flex:1}.flex-\[0_0_88\%\]{flex:0 0 88%}.flex-\[1\.4\]{flex:1.4}.shrink-0{flex-shrink:0}.grow-0{flex-grow:0}.basis-40{flex-basis:calc(var(--spacing) * 40)}.basis-\[76\%\]{flex-basis:76%}.basis-\[86\%\]{flex-basis:86%}.border-collapse{border-collapse:collapse}.origin-right{transform-origin:100%}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-px{--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-6{rotate:-6deg}.-rotate-12{rotate:-12deg}.rotate-6{rotate:6deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-start{scroll-snap-align:start}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-none{list-style-type:none}.columns-1{columns:1}.columns-2{columns:2}.break-inside-avoid{break-inside:avoid}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[46px_minmax\(0\,1fr\)\]{grid-template-columns:46px minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-7{gap:calc(var(--spacing) * 7)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-14{gap:calc(var(--spacing) * 14)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-20>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 20) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 20) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-x-10{column-gap:calc(var(--spacing) * 10)}.gap-x-12{column-gap:calc(var(--spacing) * 12)}.gap-y-0{row-gap:0}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.gap-y-5{row-gap:calc(var(--spacing) * 5)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.gap-y-14{row-gap:calc(var(--spacing) * 14)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-black\/5>:not(:last-child)){border-color:#0000000d}@supports (color:color-mix(in lab,red,red)){:where(.divide-black\/5>:not(:last-child)){border-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}:where(.divide-black\/8>:not(:last-child)){border-color:#00000014}@supports (color:color-mix(in lab,red,red)){:where(.divide-black\/8>:not(:last-child)){border-color:color-mix(in oklab,var(--color-black) 8%,transparent)}}:where(.divide-line>:not(:last-child)){border-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.divide-line>:not(:last-child)){border-color:color-mix(in oklab,currentColor 14%,transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.7)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--tpl-radius,.75rem)}.rounded-md{border-radius:calc(var(--tpl-radius,.75rem) * .7)}.rounded-sm{border-radius:calc(var(--tpl-radius,.75rem) * .4)}.rounded-xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.35)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.border-black\/5{border-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab,red,red)){.border-black\/8{border-color:color-mix(in oklab,var(--color-black) 8%,transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab,red,red)){.border-black\/10{border-color:color-mix(in oklab,var(--color-black) 10%,transparent)}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/15{border-color:color-mix(in oklab,currentcolor 15%,transparent)}}.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/20{border-color:color-mix(in oklab,currentcolor 20%,transparent)}}.border-current\/25{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/25{border-color:color-mix(in oklab,currentcolor 25%,transparent)}}.border-current\/60{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/60{border-color:color-mix(in oklab,currentcolor 60%,transparent)}}.border-current\/70{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/70{border-color:color-mix(in oklab,currentcolor 70%,transparent)}}.border-line{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-line{border-color:color-mix(in oklab,currentColor 14%,transparent)}}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.border-white\/20{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.bg-\[\#03C75A\]{background-color:#03c75a}.bg-\[\#0064FF\]{background-color:#0064ff}.bg-\[\#FEE500\]{background-color:#fee500}.bg-\[color-mix\(in_srgb\,var\(--color-surface\)_92\%\,transparent\)\]{background-color:var(--color-surface)}@supports (color:color-mix(in lab,red,red)){.bg-\[color-mix\(in_srgb\,var\(--color-surface\)_92\%\,transparent\)\]{background-color:color-mix(in srgb,var(--color-surface) 92%,transparent)}}.bg-amber-400{background-color:var(--color-amber-400)}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.bg-black\/5{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab,red,red)){.bg-black\/20{background-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab,red,red)){.bg-black\/45{background-color:color-mix(in oklab,var(--color-black) 45%,transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab,red,red)){.bg-black\/55{background-color:color-mix(in oklab,var(--color-black) 55%,transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\/70{background-color:color-mix(in oklab,var(--color-black) 70%,transparent)}}.bg-black\/90{background-color:#000000e6}@supports (color:color-mix(in lab,red,red)){.bg-black\/90{background-color:color-mix(in oklab,var(--color-black) 90%,transparent)}}.bg-black\/92{background-color:#000000eb}@supports (color:color-mix(in lab,red,red)){.bg-black\/92{background-color:color-mix(in oklab,var(--color-black) 92%,transparent)}}.bg-current,.bg-current\/5{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/5{background-color:color-mix(in oklab,currentcolor 5%,transparent)}}.bg-current\/8{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/8{background-color:color-mix(in oklab,currentcolor 8%,transparent)}}.bg-current\/15{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/15{background-color:color-mix(in oklab,currentcolor 15%,transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab,red,red)){.bg-white\/15{background-color:color-mix(in oklab,var(--color-white) 15%,transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-zinc-950{--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-zinc-950\/60{--tw-gradient-via:#09090b99}@supports (color:color-mix(in lab,red,red)){.via-zinc-950\/60{--tw-gradient-via:color-mix(in oklab, var(--color-zinc-950) 60%, transparent)}}.via-zinc-950\/60{--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-zinc-950\/30{--tw-gradient-to:#09090b4d}@supports (color:color-mix(in lab,red,red)){.to-zinc-950\/30{--tw-gradient-to:color-mix(in oklab, var(--color-zinc-950) 30%, transparent)}}.to-zinc-950\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-center{object-position:center}.p-0{padding:0}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-9{padding-inline:calc(var(--spacing) * 9)}.px-\[7\%\]{padding-inline:7%}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-px{padding-block:1px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pt-\[var\(--oasi-gap\)\]{padding-top:var(--oasi-gap)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-24{padding-bottom:calc(var(--spacing) * 24)}.pb-28{padding-bottom:calc(var(--spacing) * 28)}.pb-56{padding-bottom:calc(var(--spacing) * 56)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[calc\(3px\+0\.625rem\)\]{padding-left:calc(3px + .625rem)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-serif{font-family:Noto Serif KR,Batang,Times New Roman,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[17px\]{font-size:17px}.text-\[length\:var\(--fs-body\)\]{font-size:var(--fs-body)}.text-\[length\:var\(--fs-h2\)\]{font-size:var(--fs-h2)}.text-\[length\:var\(--fs-h3\)\]{font-size:var(--fs-h3)}.text-\[length\:var\(--fs-lead\)\]{font-size:var(--fs-lead)}.text-\[length\:var\(--fs-sm\)\]{font-size:var(--fs-sm)}.text-\[length\:var\(--fs-xs\)\]{font-size:var(--fs-xs)}.leading-\[1\.8\]{--tw-leading:1.8;line-height:1.8}.leading-\[1\.9\]{--tw-leading:1.9;line-height:1.9}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.1em\]{--tw-tracking:.1em;letter-spacing:.1em}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.24em\]{--tw-tracking:.24em;letter-spacing:.24em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.\!text-current{color:currentColor!important}.text-\[\#191600\]{color:#191600}.text-amber-700{color:var(--color-amber-700)}.text-current\/55{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-current\/55{color:color-mix(in oklab,currentcolor 55%,transparent)}}.text-muted{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-muted{color:color-mix(in oklab,currentColor 62%,transparent)}}.text-stone-600{color:var(--color-stone-600)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\/70{color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-950{color:var(--color-zinc-950)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.decoration-black\/20{text-decoration-color:#0003}@supports (color:color-mix(in lab,red,red)){.decoration-black\/20{-webkit-text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent);text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.underline-offset-\[5px\]{text-underline-offset:5px}.underline-offset-\[6px\]{text-underline-offset:6px}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-55{opacity:.55}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-95{opacity:.95}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.group-open\:rotate-45:is(:where(.group):is([open],:popover-open,:open) *){rotate:45deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media(hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.marker\:content-none ::marker{--tw-content:none;content:none}.marker\:content-none::marker{--tw-content:none;content:none}.marker\:content-none ::-webkit-details-marker{--tw-content:none;content:none}.marker\:content-none::-webkit-details-marker{--tw-content:none;content:none}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-current:hover{border-color:currentColor}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.hover\:bg-black\/40:hover{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/40:hover{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.hover\:bg-black\/65:hover{background-color:#000000a6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/65:hover{background-color:color-mix(in oklab,var(--color-black) 65%,transparent)}}.hover\:bg-black\/75:hover{background-color:#000000bf}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/75:hover{background-color:color-mix(in oklab,var(--color-black) 75%,transparent)}}.hover\:bg-black\/\[0\.02\]:hover{background-color:#00000005}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-black) 2%,transparent)}}.hover\:bg-current\/10:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/10:hover{background-color:color-mix(in oklab,currentcolor 10%,transparent)}}.hover\:bg-current\/25:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/25:hover{background-color:color-mix(in oklab,currentcolor 25%,transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab,var(--color-white) 25%,transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab,var(--color-white) 90%,transparent)}}.hover\:text-current:hover{color:currentColor}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-45:hover{opacity:.45}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-85:hover{opacity:.85}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-0:disabled{opacity:0}.disabled\:opacity-25:disabled{opacity:.25}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}@media(prefers-reduced-motion:no-preference){.motion-safe\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}@media(min-width:40rem){.sm\:top-5{top:calc(var(--spacing) * 5)}.sm\:right-5{right:calc(var(--spacing) * 5)}.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:left-6{left:calc(var(--spacing) * 6)}.sm\:mb-6{margin-bottom:calc(var(--spacing) * 6)}.sm\:mb-8{margin-bottom:calc(var(--spacing) * 8)}.sm\:mb-10{margin-bottom:calc(var(--spacing) * 10)}.sm\:mb-14{margin-bottom:calc(var(--spacing) * 14)}.sm\:-ml-4{margin-left:calc(var(--spacing) * -4)}.sm\:ml-8{margin-left:calc(var(--spacing) * 8)}.sm\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:table-cell{display:table-cell}.sm\:aspect-4\/3{aspect-ratio:4/3}.sm\:aspect-16\/9{aspect-ratio:16/9}.sm\:aspect-video{aspect-ratio:var(--aspect-video)}.sm\:w-28{width:calc(var(--spacing) * 28)}.sm\:w-32{width:calc(var(--spacing) * 32)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[18rem\]{max-width:18rem}.sm\:max-w-\[60\%\]{max-width:60%}.sm\:flex-\[0_0_72\%\]{flex:0 0 72%}.sm\:shrink-0{flex-shrink:0}.sm\:basis-1\/2{flex-basis:50%}.sm\:basis-1\/3{flex-basis:33.3333%}.sm\:basis-\[56\%\]{flex-basis:56%}.sm\:columns-2{columns:2}.sm\:columns-3{columns:3}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}.sm\:gap-8{gap:calc(var(--spacing) * 8)}.sm\:gap-10{gap:calc(var(--spacing) * 10)}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-24>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 24) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 24) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.sm\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.sm\:self-auto{align-self:auto}.sm\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.sm\:p-3\.5{padding:calc(var(--spacing) * 3.5)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:px-10{padding-inline:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:py-24{padding-block:calc(var(--spacing) * 24)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-6{padding-bottom:calc(var(--spacing) * 6)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:first\:pl-0:first-child{padding-left:0}}@media(min-width:48rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-6{grid-column:span 6/span 6}.md\:col-span-7{grid-column:span 7/span 7}.md\:col-span-12{grid-column:span 12/span 12}.md\:flex{display:flex}.md\:hidden{display:none}.md\:table-cell{display:table-cell}.md\:aspect-5\/4{aspect-ratio:5/4}.md\:h-\[78vh\]{height:78vh}.md\:min-h-\[580px\]{min-height:580px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:grid-cols-\[240px_minmax\(0\,1fr\)\]{grid-template-columns:240px minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:gap-12{gap:calc(var(--spacing) * 12)}:where(.md\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.md\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.md\:justify-self-end{justify-self:flex-end}.md\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.md\:pb-16{padding-bottom:calc(var(--spacing) * 16)}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-0{top:0}.lg\:top-12{top:calc(var(--spacing) * 12)}.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:order-none{order:0}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:col-span-12{grid-column:span 12/span 12}.lg\:mt-6{margin-top:calc(var(--spacing) * 6)}.lg\:mt-24{margin-top:calc(var(--spacing) * 24)}.lg\:mt-auto{margin-top:auto}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:aspect-3\/2{aspect-ratio:3/2}.lg\:aspect-auto{aspect-ratio:auto}.lg\:h-fit{height:fit-content}.lg\:h-full{height:100%}.lg\:h-screen{height:100vh}.lg\:min-h-\[24rem\]{min-height:24rem}.lg\:min-h-\[34rem\]{min-height:34rem}.lg\:w-\[18\.75rem\]{width:18.75rem}.lg\:w-\[260px\]{width:260px}.lg\:w-auto{width:auto}.lg\:shrink-0{flex-shrink:0}.lg\:basis-1\/3{flex-basis:33.3333%}.lg\:basis-1\/4{flex-basis:25%}.lg\:basis-auto{flex-basis:auto}.lg\:snap-none{scroll-snap-type:none}.lg\:columns-4{columns:4}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:gap-0{gap:0}.lg\:gap-3{gap:calc(var(--spacing) * 3)}.lg\:gap-4{gap:calc(var(--spacing) * 4)}.lg\:gap-5{gap:calc(var(--spacing) * 5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-7{gap:calc(var(--spacing) * 7)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-10{gap:calc(var(--spacing) * 10)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}.lg\:gap-14{gap:calc(var(--spacing) * 14)}.lg\:gap-20{gap:calc(var(--spacing) * 20)}:where(.lg\:space-y-28>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 28) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 28) * calc(1 - var(--tw-space-y-reverse)))}.lg\:gap-x-12{column-gap:calc(var(--spacing) * 12)}.lg\:overflow-visible{overflow:visible}.lg\:overflow-y-auto{overflow-y:auto}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}.lg\:px-14{padding-inline:calc(var(--spacing) * 14)}.lg\:py-10{padding-block:calc(var(--spacing) * 10)}.lg\:py-16{padding-block:calc(var(--spacing) * 16)}.lg\:pt-14{padding-top:calc(var(--spacing) * 14)}.lg\:pt-16{padding-top:calc(var(--spacing) * 16)}.lg\:pb-0{padding-bottom:0}.lg\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.lg\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.lg\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.lg\:text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}}@media(min-width:80rem){.xl\:col-span-4{grid-column:span 4/span 4}.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-7{grid-column:span 7/span 7}.xl\:col-span-8{grid-column:span 8/span 8}.xl\:col-start-1{grid-column-start:1}.xl\:col-start-5{grid-column-start:5}.xl\:row-start-1{grid-row-start:1}.xl\:-mt-24{margin-top:calc(var(--spacing) * -24)}.xl\:mr-16{margin-right:calc(var(--spacing) * 16)}.xl\:mb-12{margin-bottom:calc(var(--spacing) * 12)}.xl\:-ml-10{margin-left:calc(var(--spacing) * -10)}.xl\:ml-auto{margin-left:auto}.xl\:block{display:block}.xl\:w-\[50\%\]{width:50%}.xl\:w-\[64\%\]{width:64%}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:gap-x-10{column-gap:calc(var(--spacing) * 10)}.xl\:self-end{align-self:flex-end}.xl\:px-8{padding-inline:calc(var(--spacing) * 8)}.xl\:py-8{padding-block:calc(var(--spacing) * 8)}.xl\:py-12{padding-block:calc(var(--spacing) * 12)}.xl\:py-16{padding-block:calc(var(--spacing) * 16)}.xl\:pt-12{padding-top:calc(var(--spacing) * 12)}.xl\:pr-8{padding-right:calc(var(--spacing) * 8)}.xl\:pr-16{padding-right:calc(var(--spacing) * 16)}.xl\:pb-56{padding-bottom:calc(var(--spacing) * 56)}.xl\:pl-10{padding-left:calc(var(--spacing) * 10)}.xl\:pl-20{padding-left:calc(var(--spacing) * 20)}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\>li\]\:mb-2>li{margin-bottom:calc(var(--spacing) * 2)}.\[\&\>li\]\:mb-3>li{margin-bottom:calc(var(--spacing) * 3)}@media(min-width:40rem){.sm\:\[\&\>li\]\:mb-4>li{margin-bottom:calc(var(--spacing) * 4)}}}@font-face{font-family:Pretendard Variable;font-weight:45 920;font-style:normal;font-display:swap;src:url(/fonts/PretendardVariable.woff2)format("woff2")}html{-webkit-text-size-adjust:100%}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;word-break:keep-all;overflow-wrap:break-word;margin:0;padding:0}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}html{scrollbar-width:thin}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@media(prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}:where(.site,.site-canvas){--fs-display:clamp(1.75rem, 1.25rem + 2.2vw, 3rem);--fs-h2:clamp(1.3125rem, 1.15rem + .7vw, 1.75rem);--fs-h3:clamp(1.0625rem, 1rem + .3vw, 1.25rem);--fs-lead:clamp(.9375rem, .9rem + .22vw, 1.0625rem);--fs-body:clamp(.9375rem, .91rem + .15vw, 1rem);--fs-sm:clamp(.8125rem, .8rem + .1vw, .875rem);--fs-xs:clamp(.75rem, .74rem + .06vw, .78125rem);--section-space:clamp(2.25rem, 5vw, var(--tpl-section-space,3.5rem));--site-line:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line:color-mix(in oklab, currentColor 14%, transparent)}}:where(.site,.site-canvas){--site-line-soft:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line-soft:color-mix(in oklab, currentColor 8%, transparent)}}:where(.site,.site-canvas){--site-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-muted:color-mix(in oklab, currentColor 62%, transparent)}}:where(.site,.site-canvas) .serif{font-family:var(--tpl-font-heading,var(--font-serif));letter-spacing:var(--tpl-heading-tracking,normal)}:where(.site,.site-canvas) :is(.serif,.h2,.h3){font-synthesis-weight:none}:where(.site,.site-canvas) .tpl-border{border-width:var(--tpl-border-width,1px)}:where(.site,.site-canvas) .tpl-shadow{box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .shell{width:100%;max-width:78rem;margin-inline:auto;padding-inline:clamp(1rem,4vw,2.5rem)}:where(.site,.site-canvas) .line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:where(.site,.site-canvas) .measure{max-width:68ch}:where(.site,.site-canvas) .paper{background-image:var(--tpl-texture,none)}:where(.site,.site-canvas) .panel{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel{background-color:color-mix(in oklab,currentColor 5%,transparent)}}:where(.site,.site-canvas) .panel{border:var(--tpl-border-width,1px) solid var(--site-line);border-radius:var(--tpl-radius,.75rem);box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .panel-sunken{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel-sunken{background-color:color-mix(in oklab,currentColor 6%,transparent)}}:where(.site,.site-canvas) .h2{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h2);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.01em);line-height:1.2}:where(.site,.site-canvas) .h3{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h3);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.005em);line-height:1.35}:where(.site,.site-canvas) .label{font-size:var(--fs-xs);color:var(--site-muted);font-weight:600}:where(.site,.site-canvas) .tap{min-width:2.75rem;min-height:2.75rem}:where(.site,.site-canvas) .only-touch{display:none}@media(hover:none)and (pointer:coarse){:where(.site,.site-canvas) .only-touch{display:flex}}:where(.site,.site-canvas) .safe-b{padding-bottom:calc(.5rem + env(safe-area-inset-bottom))}:where(.site,.site-canvas) .slider-viewport{scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;overflow-x:auto}:where(.site,.site-canvas) .slider-viewport::-webkit-scrollbar{display:none}:where(.site,.site-canvas) .slider-viewport[data-slider=on]{scroll-snap-type:none;overflow-x:hidden}:where(.site,.site-canvas) .slider-track{display:flex}:where(.site,.site-canvas) .slider-track>*{scroll-snap-align:start}:where(.site,.site-canvas) .slider-viewport[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}:where(.site,.site-canvas) .text-muted{color:var(--site-muted)}:where(.site,.site-canvas) .border-line,:where(.site,.site-canvas) .divide-line>:not(:last-child){border-color:var(--site-line)}:root{--tpl-primary:#18181b;--tpl-secondary:#52525b;--tpl-bg:#fff;--tpl-card:#fafafa;--tpl-text:#09090b;--tpl-accent:#2563eb;--tpl-surface:#fff;--tpl-surface-alt:#f5f5f4;--tpl-inverse:#1c1917;--tpl-border:#e7e5e4;--tpl-font-heading:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-font-body:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-radius:.75rem;--tpl-border-width:1px;--tpl-shadow:0 1px 2px #0000000f;--tpl-heading-tracking:-.02em;--tpl-heading-weight:700;--tpl-section-space:4rem}html{scroll-behavior:smooth;scroll-padding-top:5.5rem}body{background-color:var(--color-surface);background-image:var(--tpl-texture,none);color:var(--color-ink);font-family:var(--tpl-font-body,var(--font-sans));font-size:var(--fs-body);font-feature-settings:"tnum" 1;padding-bottom:env(safe-area-inset-bottom);line-height:1.7}:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0} diff --git a/solution/site/scripts/mockup/vendor/retired/README.md b/solution/site/scripts/mockup/vendor/retired/README.md new file mode 100644 index 0000000..0f471c5 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/retired/README.md @@ -0,0 +1,22 @@ +# 물러난 번들 — 지우지 않는다 + +여기 있는 파일은 **더 이상 아무 HTML 도 가리키지 않는** 옛 번들이다. 그래도 서버에는 올린다. + +## 왜 + +`/s/` 는 5분 캐시(`max-age=300`)다. 새 `index.html` 을 올려도 그동안 손님 브라우저에는 +**옛 HTML** 이 남아 있고, 그 HTML 은 옛 해시 이름으로 css·js 를 부른다. +그 파일을 서버에서 지우면 **그 손님에게는 스타일도 스크립트도 404 다** — 화면은 뜨는데 +헤더가 안 붙고 푸터 색이 빠진 민짜 문서가 된다. + +실측(2026-09-14): 번들을 갈면서 옛 두 개를 `docker exec rm` 으로 지웠더니 바로 그 증상이 +났다. "헤더가 스티키가 아니고 푸터가 투명해졌다" — 파일이 사라진 것이지 CSS 가 바뀐 게 아니다. +제품 쪽 프리렌더가 옛 자산을 30일 보관하는 것도 같은 이유다(`prerender.ts ASSET_RETENTION_DAYS`). + +## 규칙 + +- 번들을 갈면 옛 파일을 **지우지 말고 이리로 옮긴다.** `vendor/` 뿌리에는 현재 한 벌만 둔다 + (`patch_stay.py` 가 뿌리에 js·css 각 하나임을 확인한다). +- 배포할 때 뿌리와 이 폴더를 **둘 다** 서버 `vendor/` 에 넣는다: + `docker cp vendor/retired/. $C:/app/solution/site/out/s/stay/vendor/` +- 30일쯤 지나 캐시가 다 빠지면 지워도 된다. 급할 것 없다 — 합쳐서 450KB 다. diff --git a/solution/site/scripts/mockup/vendor/retired/index-C3fFNl3r.css b/solution/site/scripts/mockup/vendor/retired/index-C3fFNl3r.css new file mode 100644 index 0000000..7450f49 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/retired/index-C3fFNl3r.css @@ -0,0 +1 @@ +#w4d-mini{display:inline-flex;align-items:center;gap:2px;margin-right:6px;padding-right:8px;border-right:1px solid var(--tpl-border, #bcb49e)}#w4d-mini button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;color:var(--tpl-text, #1b1a15);background:transparent;border:0;border-radius:var(--tpl-radius, 0);cursor:pointer;opacity:.75}#w4d-mini button:hover{opacity:1;background:#1b1a150f}#w4d-mini svg{width:16px;height:16px}#w4d-now{max-width:150px;overflow:hidden;font-size:11.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis;color:var(--tpl-accent, #bf2f1b);padding-right:2px}#w4d-mini[data-playing="0"] #w4d-now{opacity:.55;font-weight:400}@media(max-width:860px){#w4d-now{display:none}}#w4d-tape{display:inline-flex;align-items:center;padding:0 4px 0 2px;color:var(--tpl-text, #1b1a15);opacity:.8}#w4d-tape svg{width:20px;height:20px}#w4d-mini[data-playing="1"] #w4d-tape{color:var(--tpl-accent, #bf2f1b);opacity:1}#w4d-mini[data-playing="1"] .w4d-reel{transform-box:fill-box;transform-origin:center;animation:w4d-reel 2.6s linear infinite}@keyframes w4d-reel{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.w4d-reel{animation:none!important}}#w4d-mini button[data-playing="1"]{color:var(--tpl-accent, #bf2f1b);opacity:1}@media(max-width:400px){#w4d-mini{margin-right:2px;padding-right:4px}#w4d-mini button{width:27px;height:27px}}#w4d-panel{position:fixed;z-index:45;width:344px;max-width:calc(100vw - 16px);max-height:min(64vh,520px);display:flex;flex-direction:column;overflow:hidden;font-family:var(--tpl-font-body, serif);color:var(--tpl-text, #1b1a15);background:var(--tpl-card, #efe7d3);border:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);box-shadow:var(--tpl-shadow, 4px 4px 0 rgb(27 26 21 / .16))}#w4d-panel[hidden]{display:none}@media(max-width:640px){#w4d-panel{width:auto}}#w4d-panel .w4d-head{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding:11px 8px 11px 14px;border-bottom:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15)}.w4d-head-t{font-size:12px;font-weight:700;letter-spacing:.06em}.w4d-head-n{flex:1 1 auto;font-size:11px;opacity:.5}#w4d-panel .w4d-head button{display:inline-flex;width:28px;height:28px;flex:0 0 auto;align-items:center;justify-content:center;color:inherit;background:transparent;border:0;cursor:pointer;opacity:.6}#w4d-panel .w4d-head button:hover{opacity:1}#w4d-panel .w4d-head svg{width:14px;height:14px}#w4d-panel ol{flex:1 1 auto;margin:0;padding:4px 0;overflow-y:auto;list-style:none;-webkit-overflow-scrolling:touch}.w4d-item{display:flex;width:100%;min-height:52px;align-items:center;gap:11px;padding:8px 14px;color:inherit;background:transparent;border:0;font:inherit;text-align:left;cursor:pointer}.w4d-item+.w4d-item{border-top:1px dashed var(--tpl-border, #bcb49e)}.w4d-item:hover{background:#1b1a150d}.w4d-item[aria-current=true]{background:#1b1a150f}.w4d-item[aria-current=true] .w4d-t{color:var(--tpl-accent, #bf2f1b)}.w4d-mark{position:relative;flex:0 0 26px;height:26px}.w4d-disc-sm{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%;background:radial-gradient(circle at 50% 50%,var(--w4d-lbl, #bf2f1b) 0 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,rgb(255 255 255 / .12) 0 1px,transparent 1px 3px),#14110f}.w4d-disc-sm:after{content:"";position:absolute;top:45%;right:45%;bottom:45%;left:45%;border-radius:50%;background:var(--tpl-card, #efe7d3)}.w4d-eq{position:absolute;top:0;right:0;bottom:0;left:0;display:none;align-items:flex-end;justify-content:center;gap:3px;padding-bottom:4px}.w4d-eq i{width:3px;height:6px;background:var(--tpl-accent, #bf2f1b);animation:w4d-eq .9s ease-in-out infinite}.w4d-eq i:nth-child(2){animation-delay:.18s}.w4d-eq i:nth-child(3){animation-delay:.36s}@keyframes w4d-eq{0%,to{height:5px}50%{height:17px}}.w4d-item[data-playing="1"] .w4d-disc-sm{display:none}.w4d-item[data-playing="1"] .w4d-eq{display:flex}@media(prefers-reduced-motion:reduce){.w4d-eq i{animation:none;height:11px}}.w4d-info{min-width:0;flex:1 1 auto}.w4d-t{display:block;overflow:hidden;font-size:13.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis}.w4d-sub{display:block;margin-top:2px;overflow:hidden;font-size:11px;white-space:nowrap;text-overflow:ellipsis;opacity:.55}.w4d-time{flex:0 0 auto;font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}#w4d-lyrics-box{padding:10px 12px 14px;border-top:1px dashed var(--tpl-border, #bcb49e);white-space:pre-line;font-size:12px;line-height:1.9;overflow-y:auto}#w4d-lyrics-box[hidden]{display:none}.w4-sky{position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:none;overflow:hidden}.w4-sky[data-mood=비]:before{content:"";position:absolute;top:-20%;right:-10%;bottom:-20%;left:-10%;background-image:repeating-linear-gradient(102deg,transparent 0 9px,currentcolor 9px 10px,transparent 10px 22px);opacity:.13;animation:w4-rain .9s linear infinite}@keyframes w4-rain{to{transform:translate3d(-22px,108px,0)}}.w4-sky[data-mood=맑음]:before{content:"";position:absolute;right:6%;top:-40%;width:26rem;aspect-ratio:1;background-image:conic-gradient(from 0deg,currentcolor 0 2deg,transparent 2deg 30deg);opacity:.07;animation:w4-spin 70s linear infinite}.w4-sky[data-mood=맑음]:after{content:"";position:absolute;right:10%;top:-14%;width:15rem;aspect-ratio:1;border-radius:50%;background-image:radial-gradient(circle,currentcolor 0%,transparent 62%);opacity:.11;animation:w4-breathe 9s ease-in-out infinite}@keyframes w4-spin{to{transform:rotate(360deg)}}@keyframes w4-breathe{50%{opacity:.2}}.w4-sky[data-mood=흐림]:before,.w4-sky[data-mood=흐림]:after{content:"";position:absolute;top:-30%;right:-60%;bottom:-30%;left:-60%;background-image:radial-gradient(ellipse 22% 46% at 18% 42%,currentcolor 0%,transparent 70%),radial-gradient(ellipse 30% 40% at 62% 30%,currentcolor 0%,transparent 72%);opacity:.08;animation:w4-drift 44s linear infinite}.w4-sky[data-mood=흐림]:after{opacity:.05;animation-duration:78s;animation-direction:reverse}@keyframes w4-drift{to{transform:translate3d(28%,0,0)}}.w4-sky[data-mood=눈]:before{content:"";position:absolute;top:-20%;right:0;bottom:-20%;left:0;background-image:radial-gradient(circle at 12% 10%,currentcolor 1.4px,transparent 1.8px),radial-gradient(circle at 47% 34%,currentcolor 1.1px,transparent 1.5px),radial-gradient(circle at 78% 18%,currentcolor 1.6px,transparent 2px);background-size:9rem 9rem;opacity:.22;animation:w4-snow 14s linear infinite}@keyframes w4-snow{to{transform:translate3d(-2rem,9rem,0)}}@media(prefers-reduced-motion:reduce){.w4-sky:before,.w4-sky:after{animation:none!important}}.w4-paper{background-image:repeating-linear-gradient(0deg,color-mix(in oklab,currentcolor 4%,transparent) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,color-mix(in oklab,currentcolor 3%,transparent) 0 1px,transparent 1px 4px)}.w4-disc{background:radial-gradient(circle at 50% 50%,transparent 0 15.5%,var(--lbl) 15.5% 33%,transparent 33%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.4px,var(--w4-vinyl) 1.4px 3px),var(--w4-vinyl);box-shadow:inset 0 0 40px #0000008c}.w4-disc-sheen{background:conic-gradient(from 210deg,rgb(255 255 255 / 12%),transparent 22%,transparent 70%,rgb(255 255 255 / 7%))}.w4-spin{animation:w4-rev 2.2s linear infinite}@keyframes w4-rev{to{transform:rotate(360deg)}}.w4-disc-mini{background:radial-gradient(circle at 50% 50%,transparent 0 14%,var(--lbl) 14% 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.2px,var(--w4-vinyl) 1.2px 2.6px),var(--w4-vinyl);box-shadow:0 3px 10px #00000059}.w4-perf{background:repeating-linear-gradient(90deg,transparent 0 8px,var(--tear) 8px 9px)}.w4-dash{border-top:1px dashed currentcolor}.w4-film-perf{background:repeating-linear-gradient(90deg,currentcolor 0 9px,transparent 9px 21px)}.w4-flip{perspective:900px}.w4-flip-inner{transform-style:preserve-3d;transition:transform .5s}.w4-flip-on .w4-flip-inner{transform:rotateY(180deg)}.w4-flip-face{backface-visibility:hidden}.w4-flip-back{transform:rotateY(180deg)}.w4-scroll{overflow-x:auto;scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch}.w4-scroll::-webkit-scrollbar{display:none}.w4-scroll[data-slider=on]{overflow-x:hidden;scroll-snap-type:none}.w4-scroll[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}@media(prefers-reduced-motion:reduce){.w4-spin{animation:none}.w4-flip-inner{transition:none}}@media print{.w4-flip,.w4-flip-inner{height:auto!important;transform:none!important}.w4-flip-face{position:static!important;backface-visibility:visible!important;transform:none!important}}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--font-serif:"Noto Serif KR", "Batang", "Times New Roman", serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-zinc-950:oklch(14.1% .005 285.823);--color-stone-600:oklch(44.4% .011 73.639);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-2xl:42rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--leading-loose:2;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--default-mono-font-family:var(--font-mono);--color-brand:var(--tpl-primary);--color-surface:var(--tpl-bg);--color-surface-alt:var(--tpl-card);--color-ink:var(--tpl-text);--color-accent:var(--tpl-accent);--color-line:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-line:color-mix(in oklab, currentColor 14%, transparent)}}:root,:host{--color-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-muted:color-mix(in oklab, currentColor 62%, transparent)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-2{inset:calc(var(--spacing) * 2)}.inset-x-0{inset-inline:0}.inset-y-\[3px\]{inset-block:3px}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(100\%\+0\.5rem\)\]{top:calc(100% + .5rem)}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-2\.5{bottom:calc(var(--spacing) * 2.5)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-4{left:calc(var(--spacing) * 4)}.left-full{left:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.col-span-12{grid-column:span 12/span 12}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-3{margin-block:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mt-auto{margin-top:auto}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-7{margin-bottom:calc(var(--spacing) * 7)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-10{margin-left:calc(var(--spacing) * -10)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-\[9\]{-webkit-line-clamp:9;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.table{display:table}.aspect-3\/2{aspect-ratio:3/2}.aspect-3\/4{aspect-ratio:3/4}.aspect-4\/3{aspect-ratio:4/3}.aspect-16\/10{aspect-ratio:16/10}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-\[17px\]{width:17px;height:17px}.size-\[54px\]{width:54px;height:54px}.size-\[76px\]{width:76px;height:76px}.size-\[220px\]{width:220px;height:220px}.size-full{width:100%;height:100%}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-9{height:calc(var(--spacing) * 9)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-48{height:calc(var(--spacing) * 48)}.h-\[1em\]{height:1em}.h-\[6px\]{height:6px}.h-\[22px\]{height:22px}.h-\[56px\]{height:56px}.h-\[260px\]{height:260px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[clamp\(15rem\,24vw\,21rem\)\]{height:clamp(15rem,24vw,21rem)}.h-\[clamp\(17rem\,27vw\,24rem\)\]{height:clamp(17rem,27vw,24rem)}.h-auto{height:auto}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[26rem\]{max-height:26rem}.max-h-\[46vh\]{max-height:46vh}.max-h-\[72vh\]{max-height:72vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[88vh\]{max-height:88vh}.max-h-full{max-height:100%}.min-h-\[2rem\]{min-height:2rem}.min-h-\[16rem\]{min-height:16rem}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-\[3px\]{width:3px}.w-\[44px\]{width:44px}.w-\[62px\]{width:62px}.w-\[110px\]{width:110px}.w-\[168px\]{width:168px}.w-\[210px\]{width:210px}.w-\[228px\]{width:228px}.w-\[254px\]{width:254px}.w-\[262px\]{width:262px}.w-\[304px\]{width:304px}.w-\[320px\]{width:320px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[34rem\]{max-width:34rem}.max-w-\[72rem\]{max-width:72rem}.max-w-full{max-width:100%}.max-w-none{max-width:none}.min-w-0{min-width:0}.min-w-\[22px\]{min-width:22px}.min-w-\[30rem\]{min-width:30rem}.min-w-\[34rem\]{min-width:34rem}.flex-1{flex:1}.flex-\[1\.4\]{flex:1.4}.shrink-0{flex-shrink:0}.grow-0{flex-grow:0}.basis-40{flex-basis:calc(var(--spacing) * 40)}.basis-\[76\%\]{flex-basis:76%}.basis-\[86\%\]{flex-basis:86%}.border-collapse{border-collapse:collapse}.origin-right{transform-origin:100%}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-px{--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-6{rotate:-6deg}.rotate-6{rotate:6deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-start{scroll-snap-align:start}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-none{list-style-type:none}.columns-1{columns:1}.columns-2{columns:2}.break-inside-avoid{break-inside:avoid}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[46px_minmax\(0\,1fr\)\]{grid-template-columns:46px minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-7{gap:calc(var(--spacing) * 7)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-14{gap:calc(var(--spacing) * 14)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-20>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 20) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 20) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-x-10{column-gap:calc(var(--spacing) * 10)}.gap-x-12{column-gap:calc(var(--spacing) * 12)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.gap-y-5{row-gap:calc(var(--spacing) * 5)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.gap-y-14{row-gap:calc(var(--spacing) * 14)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-black\/5>:not(:last-child)){border-color:#0000000d}@supports (color:color-mix(in lab,red,red)){:where(.divide-black\/5>:not(:last-child)){border-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}:where(.divide-line>:not(:last-child)){border-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.divide-line>:not(:last-child)){border-color:color-mix(in oklab,currentColor 14%,transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-2xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.7)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--tpl-radius,.75rem)}.rounded-md{border-radius:calc(var(--tpl-radius,.75rem) * .7)}.rounded-sm{border-radius:calc(var(--tpl-radius,.75rem) * .4)}.rounded-xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.35)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab,red,red)){.border-black\/8{border-color:color-mix(in oklab,var(--color-black) 8%,transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab,red,red)){.border-black\/10{border-color:color-mix(in oklab,var(--color-black) 10%,transparent)}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/15{border-color:color-mix(in oklab,currentcolor 15%,transparent)}}.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/20{border-color:color-mix(in oklab,currentcolor 20%,transparent)}}.border-current\/25{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/25{border-color:color-mix(in oklab,currentcolor 25%,transparent)}}.border-current\/60{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/60{border-color:color-mix(in oklab,currentcolor 60%,transparent)}}.border-current\/70{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/70{border-color:color-mix(in oklab,currentcolor 70%,transparent)}}.border-line{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-line{border-color:color-mix(in oklab,currentColor 14%,transparent)}}.border-transparent{border-color:#0000}.bg-\[\#03C75A\]{background-color:#03c75a}.bg-\[\#0064FF\]{background-color:#0064ff}.bg-\[\#FEE500\]{background-color:#fee500}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab,red,red)){.bg-black\/45{background-color:color-mix(in oklab,var(--color-black) 45%,transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab,red,red)){.bg-black\/55{background-color:color-mix(in oklab,var(--color-black) 55%,transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\/70{background-color:color-mix(in oklab,var(--color-black) 70%,transparent)}}.bg-black\/92{background-color:#000000eb}@supports (color:color-mix(in lab,red,red)){.bg-black\/92{background-color:color-mix(in oklab,var(--color-black) 92%,transparent)}}.bg-current,.bg-current\/5{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/5{background-color:color-mix(in oklab,currentcolor 5%,transparent)}}.bg-current\/8{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/8{background-color:color-mix(in oklab,currentcolor 8%,transparent)}}.bg-current\/15{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/15{background-color:color-mix(in oklab,currentcolor 15%,transparent)}}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-center{object-position:center}.p-0{padding:0}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-9{padding-inline:calc(var(--spacing) * 9)}.px-\[7\%\]{padding-inline:7%}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pt-\[var\(--oasi-gap\)\]{padding-top:var(--oasi-gap)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-24{padding-bottom:calc(var(--spacing) * 24)}.pb-28{padding-bottom:calc(var(--spacing) * 28)}.pb-56{padding-bottom:calc(var(--spacing) * 56)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[calc\(3px\+0\.625rem\)\]{padding-left:calc(3px + .625rem)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-serif{font-family:Noto Serif KR,Batang,Times New Roman,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[17px\]{font-size:17px}.text-\[length\:var\(--fs-body\)\]{font-size:var(--fs-body)}.text-\[length\:var\(--fs-h2\)\]{font-size:var(--fs-h2)}.text-\[length\:var\(--fs-h3\)\]{font-size:var(--fs-h3)}.text-\[length\:var\(--fs-lead\)\]{font-size:var(--fs-lead)}.text-\[length\:var\(--fs-sm\)\]{font-size:var(--fs-sm)}.text-\[length\:var\(--fs-xs\)\]{font-size:var(--fs-xs)}.leading-\[1\.8\]{--tw-leading:1.8;line-height:1.8}.leading-\[1\.9\]{--tw-leading:1.9;line-height:1.9}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.1em\]{--tw-tracking:.1em;letter-spacing:.1em}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.24em\]{--tw-tracking:.24em;letter-spacing:.24em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.\!text-current{color:currentColor!important}.text-\[\#191600\]{color:#191600}.text-current\/55{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-current\/55{color:color-mix(in oklab,currentcolor 55%,transparent)}}.text-muted{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-muted{color:color-mix(in oklab,currentColor 62%,transparent)}}.text-stone-600{color:var(--color-stone-600)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\/70{color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.decoration-black\/20{text-decoration-color:#0003}@supports (color:color-mix(in lab,red,red)){.decoration-black\/20{-webkit-text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent);text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.underline-offset-\[5px\]{text-underline-offset:5px}.underline-offset-\[6px\]{text-underline-offset:6px}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-55{opacity:.55}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-95{opacity:.95}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.group-open\:rotate-45:is(:where(.group):is([open],:popover-open,:open) *){rotate:45deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media(hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.marker\:content-none ::marker{--tw-content:none;content:none}.marker\:content-none::marker{--tw-content:none;content:none}.marker\:content-none ::-webkit-details-marker{--tw-content:none;content:none}.marker\:content-none::-webkit-details-marker{--tw-content:none;content:none}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-current:hover{border-color:currentColor}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.hover\:bg-black\/65:hover{background-color:#000000a6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/65:hover{background-color:color-mix(in oklab,var(--color-black) 65%,transparent)}}.hover\:bg-black\/75:hover{background-color:#000000bf}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/75:hover{background-color:color-mix(in oklab,var(--color-black) 75%,transparent)}}.hover\:bg-current\/10:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/10:hover{background-color:color-mix(in oklab,currentcolor 10%,transparent)}}.hover\:bg-current\/25:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/25:hover{background-color:color-mix(in oklab,currentcolor 25%,transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab,var(--color-white) 25%,transparent)}}.hover\:text-current:hover{color:currentColor}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-45:hover{opacity:.45}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-85:hover{opacity:.85}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-0:disabled{opacity:0}.disabled\:opacity-25:disabled{opacity:.25}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}@media(prefers-reduced-motion:no-preference){.motion-safe\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}@media(min-width:40rem){.sm\:top-5{top:calc(var(--spacing) * 5)}.sm\:right-5{right:calc(var(--spacing) * 5)}.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:left-6{left:calc(var(--spacing) * 6)}.sm\:mb-8{margin-bottom:calc(var(--spacing) * 8)}.sm\:mb-10{margin-bottom:calc(var(--spacing) * 10)}.sm\:mb-14{margin-bottom:calc(var(--spacing) * 14)}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:aspect-video{aspect-ratio:var(--aspect-video)}.sm\:w-28{width:calc(var(--spacing) * 28)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:max-w-\[18rem\]{max-width:18rem}.sm\:max-w-\[60\%\]{max-width:60%}.sm\:shrink-0{flex-shrink:0}.sm\:basis-1\/3{flex-basis:33.3333%}.sm\:basis-\[56\%\]{flex-basis:56%}.sm\:columns-2{columns:2}.sm\:columns-3{columns:3}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}.sm\:gap-8{gap:calc(var(--spacing) * 8)}.sm\:gap-10{gap:calc(var(--spacing) * 10)}:where(.sm\:space-y-24>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 24) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 24) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.sm\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.sm\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:px-10{padding-inline:calc(var(--spacing) * 10)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}.sm\:py-24{padding-block:calc(var(--spacing) * 24)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-right{text-align:right}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:first\:pl-0:first-child{padding-left:0}}@media(min-width:48rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-7{grid-column:span 7/span 7}.md\:table-cell{display:table-cell}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:grid-cols-\[240px_minmax\(0\,1fr\)\]{grid-template-columns:240px minmax(0,1fr)}.md\:gap-12{gap:calc(var(--spacing) * 12)}.md\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.md\:pb-16{padding-bottom:calc(var(--spacing) * 16)}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-0{top:0}.lg\:top-12{top:calc(var(--spacing) * 12)}.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:order-none{order:0}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:col-span-12{grid-column:span 12/span 12}.lg\:mt-6{margin-top:calc(var(--spacing) * 6)}.lg\:mt-24{margin-top:calc(var(--spacing) * 24)}.lg\:mt-auto{margin-top:auto}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:aspect-3\/2{aspect-ratio:3/2}.lg\:aspect-auto{aspect-ratio:auto}.lg\:h-fit{height:fit-content}.lg\:h-full{height:100%}.lg\:h-screen{height:100vh}.lg\:min-h-\[24rem\]{min-height:24rem}.lg\:min-h-\[34rem\]{min-height:34rem}.lg\:w-\[18\.75rem\]{width:18.75rem}.lg\:w-\[260px\]{width:260px}.lg\:w-auto{width:auto}.lg\:shrink-0{flex-shrink:0}.lg\:basis-1\/4{flex-basis:25%}.lg\:basis-auto{flex-basis:auto}.lg\:snap-none{scroll-snap-type:none}.lg\:columns-4{columns:4}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:gap-0{gap:0}.lg\:gap-3{gap:calc(var(--spacing) * 3)}.lg\:gap-4{gap:calc(var(--spacing) * 4)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-7{gap:calc(var(--spacing) * 7)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-10{gap:calc(var(--spacing) * 10)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}.lg\:gap-14{gap:calc(var(--spacing) * 14)}.lg\:gap-20{gap:calc(var(--spacing) * 20)}:where(.lg\:space-y-28>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 28) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 28) * calc(1 - var(--tw-space-y-reverse)))}.lg\:gap-x-12{column-gap:calc(var(--spacing) * 12)}.lg\:overflow-visible{overflow:visible}.lg\:overflow-y-auto{overflow-y:auto}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}.lg\:px-14{padding-inline:calc(var(--spacing) * 14)}.lg\:py-10{padding-block:calc(var(--spacing) * 10)}.lg\:py-16{padding-block:calc(var(--spacing) * 16)}.lg\:pt-14{padding-top:calc(var(--spacing) * 14)}.lg\:pt-16{padding-top:calc(var(--spacing) * 16)}.lg\:pb-0{padding-bottom:0}.lg\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.lg\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.lg\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}}@media(min-width:80rem){.xl\:col-span-4{grid-column:span 4/span 4}.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-7{grid-column:span 7/span 7}.xl\:col-span-8{grid-column:span 8/span 8}.xl\:col-start-1{grid-column-start:1}.xl\:col-start-5{grid-column-start:5}.xl\:row-start-1{grid-row-start:1}.xl\:-mt-24{margin-top:calc(var(--spacing) * -24)}.xl\:mr-16{margin-right:calc(var(--spacing) * 16)}.xl\:mb-12{margin-bottom:calc(var(--spacing) * 12)}.xl\:-ml-10{margin-left:calc(var(--spacing) * -10)}.xl\:ml-auto{margin-left:auto}.xl\:block{display:block}.xl\:w-\[50\%\]{width:50%}.xl\:w-\[64\%\]{width:64%}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:gap-x-10{column-gap:calc(var(--spacing) * 10)}.xl\:self-end{align-self:flex-end}.xl\:px-8{padding-inline:calc(var(--spacing) * 8)}.xl\:py-8{padding-block:calc(var(--spacing) * 8)}.xl\:py-12{padding-block:calc(var(--spacing) * 12)}.xl\:py-16{padding-block:calc(var(--spacing) * 16)}.xl\:pt-12{padding-top:calc(var(--spacing) * 12)}.xl\:pr-8{padding-right:calc(var(--spacing) * 8)}.xl\:pr-16{padding-right:calc(var(--spacing) * 16)}.xl\:pb-56{padding-bottom:calc(var(--spacing) * 56)}.xl\:pl-10{padding-left:calc(var(--spacing) * 10)}.xl\:pl-20{padding-left:calc(var(--spacing) * 20)}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\>li\]\:mb-3>li{margin-bottom:calc(var(--spacing) * 3)}}@font-face{font-family:Pretendard Variable;font-weight:45 920;font-style:normal;font-display:swap;src:url(/fonts/PretendardVariable.woff2)format("woff2")}html{-webkit-text-size-adjust:100%}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;word-break:keep-all;overflow-wrap:break-word;margin:0;padding:0}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}html{scrollbar-width:thin}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@media(prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}:where(.site,.site-canvas){--fs-display:clamp(1.75rem, 1.25rem + 2.2vw, 3rem);--fs-h2:clamp(1.3125rem, 1.15rem + .7vw, 1.75rem);--fs-h3:clamp(1.0625rem, 1rem + .3vw, 1.25rem);--fs-lead:clamp(.9375rem, .9rem + .22vw, 1.0625rem);--fs-body:clamp(.9375rem, .91rem + .15vw, 1rem);--fs-sm:clamp(.8125rem, .8rem + .1vw, .875rem);--fs-xs:clamp(.75rem, .74rem + .06vw, .78125rem);--section-space:clamp(2.25rem, 5vw, var(--tpl-section-space,3.5rem));--site-line:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line:color-mix(in oklab, currentColor 14%, transparent)}}:where(.site,.site-canvas){--site-line-soft:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line-soft:color-mix(in oklab, currentColor 8%, transparent)}}:where(.site,.site-canvas){--site-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-muted:color-mix(in oklab, currentColor 62%, transparent)}}:where(.site,.site-canvas) .serif{font-family:var(--tpl-font-heading,var(--font-serif));letter-spacing:var(--tpl-heading-tracking,normal)}:where(.site,.site-canvas) :is(.serif,.h2,.h3){font-synthesis-weight:none}:where(.site,.site-canvas) .tpl-border{border-width:var(--tpl-border-width,1px)}:where(.site,.site-canvas) .tpl-shadow{box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .shell{width:100%;max-width:78rem;margin-inline:auto;padding-inline:clamp(1rem,4vw,2.5rem)}:where(.site,.site-canvas) .line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:where(.site,.site-canvas) .measure{max-width:68ch}:where(.site,.site-canvas) .paper{background-image:var(--tpl-texture,none)}:where(.site,.site-canvas) .panel{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel{background-color:color-mix(in oklab,currentColor 5%,transparent)}}:where(.site,.site-canvas) .panel{border:var(--tpl-border-width,1px) solid var(--site-line);border-radius:var(--tpl-radius,.75rem);box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .panel-sunken{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel-sunken{background-color:color-mix(in oklab,currentColor 6%,transparent)}}:where(.site,.site-canvas) .h2{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h2);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.01em);line-height:1.2}:where(.site,.site-canvas) .h3{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h3);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.005em);line-height:1.35}:where(.site,.site-canvas) .label{font-size:var(--fs-xs);color:var(--site-muted);font-weight:600}:where(.site,.site-canvas) .tap{min-width:2.75rem;min-height:2.75rem}:where(.site,.site-canvas) .only-touch{display:none}@media(hover:none)and (pointer:coarse){:where(.site,.site-canvas) .only-touch{display:flex}}:where(.site,.site-canvas) .safe-b{padding-bottom:calc(.5rem + env(safe-area-inset-bottom))}:where(.site,.site-canvas) .slider-viewport{scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;overflow-x:auto}:where(.site,.site-canvas) .slider-viewport::-webkit-scrollbar{display:none}:where(.site,.site-canvas) .slider-viewport[data-slider=on]{scroll-snap-type:none;overflow-x:hidden}:where(.site,.site-canvas) .slider-track{display:flex}:where(.site,.site-canvas) .slider-track>*{scroll-snap-align:start}:where(.site,.site-canvas) .slider-viewport[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}:where(.site,.site-canvas) .text-muted{color:var(--site-muted)}:where(.site,.site-canvas) .border-line,:where(.site,.site-canvas) .divide-line>:not(:last-child){border-color:var(--site-line)}:root{--tpl-primary:#18181b;--tpl-secondary:#52525b;--tpl-bg:#fff;--tpl-card:#fafafa;--tpl-text:#09090b;--tpl-accent:#2563eb;--tpl-surface:#fff;--tpl-surface-alt:#f5f5f4;--tpl-inverse:#1c1917;--tpl-border:#e7e5e4;--tpl-font-heading:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-font-body:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-radius:.75rem;--tpl-border-width:1px;--tpl-shadow:0 1px 2px #0000000f;--tpl-heading-tracking:-.02em;--tpl-heading-weight:700;--tpl-section-space:4rem}html{scroll-behavior:smooth;scroll-padding-top:5.5rem}body{background-color:var(--color-surface);background-image:var(--tpl-texture,none);color:var(--color-ink);font-family:var(--tpl-font-body,var(--font-sans));font-size:var(--fs-body);font-feature-settings:"tnum" 1;padding-bottom:env(safe-area-inset-bottom);line-height:1.7}:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0} diff --git a/solution/site/scripts/mockup/vendor/retired/index-D9cPXCE3.js b/solution/site/scripts/mockup/vendor/retired/index-D9cPXCE3.js new file mode 100644 index 0000000..2db0b85 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/retired/index-D9cPXCE3.js @@ -0,0 +1,340 @@ +(function(){const c=document.createElement("link").relList;if(c&&c.supports&&c.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))u(f);new MutationObserver(f=>{for(const d of f)if(d.type==="childList")for(const h of d.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&u(h)}).observe(document,{childList:!0,subtree:!0});function o(f){const d={};return f.integrity&&(d.integrity=f.integrity),f.referrerPolicy&&(d.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?d.credentials="include":f.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(f){if(f.ep)return;f.ep=!0;const d=o(f);fetch(f.href,d)}})();const Pg="modulepreload",eb=function(i){return"/"+i},xh={},tb=function(c,o,u){let f=Promise.resolve();if(o&&o.length>0){let h=function(x){return Promise.all(x.map(v=>Promise.resolve(v).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const p=document.querySelector("meta[property=csp-nonce]"),g=(p==null?void 0:p.nonce)||(p==null?void 0:p.getAttribute("nonce"));f=h(o.map(x=>{if(x=eb(x),x in xh)return;xh[x]=!0;const v=x.endsWith(".css"),y=v?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${x}"]${y}`))return;const z=document.createElement("link");if(z.rel=v?"stylesheet":Pg,v||(z.as="script"),z.crossOrigin="",z.href=x,g&&z.setAttribute("nonce",g),document.head.appendChild(z),v)return new Promise((w,N)=>{z.addEventListener("load",w),z.addEventListener("error",()=>N(new Error(`Unable to preload CSS for ${x}`)))})}))}function d(h){const p=new Event("vite:preloadError",{cancelable:!0});if(p.payload=h,window.dispatchEvent(p),!p.defaultPrevented)throw h}return f.then(h=>{for(const p of h||[])p.status==="rejected"&&d(p.reason);return c().catch(d)})};var Mo={exports:{}},zs={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gh;function lb(){if(gh)return zs;gh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.fragment");function o(u,f,d){var h=null;if(d!==void 0&&(h=""+d),f.key!==void 0&&(h=""+f.key),"key"in f){d={};for(var p in f)p!=="key"&&(d[p]=f[p])}else d=f;return f=d.ref,{$$typeof:i,type:u,key:h,ref:f!==void 0?f:null,props:d}}return zs.Fragment=c,zs.jsx=o,zs.jsxs=o,zs}var bh;function ab(){return bh||(bh=1,Mo.exports=lb()),Mo.exports}var n=ab(),ko={exports:{}},ue={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var vh;function nb(){if(vh)return ue;vh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),h=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),v=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),z=Symbol.iterator;function w(S){return S===null||typeof S!="object"?null:(S=z&&S[z]||S["@@iterator"],typeof S=="function"?S:null)}var N={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},A=Object.assign,D={};function U(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}U.prototype.isReactComponent={},U.prototype.setState=function(S,L){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,L,"setState")},U.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function X(){}X.prototype=U.prototype;function Z(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}var P=Z.prototype=new X;P.constructor=Z,A(P,U.prototype),P.isPureReactComponent=!0;var $=Array.isArray;function K(){}var G={H:null,A:null,T:null,S:null},ee=Object.prototype.hasOwnProperty;function se(S,L,V){var J=V.ref;return{$$typeof:i,type:S,key:L,ref:J!==void 0?J:null,props:V}}function Q(S,L){return se(S.type,L,S.props)}function ae(S){return typeof S=="object"&&S!==null&&S.$$typeof===i}function ye(S){var L={"=":"=0",":":"=2"};return"$"+S.replace(/[=:]/g,function(V){return L[V]})}var Ge=/\/+/g;function Qe(S,L){return typeof S=="object"&&S!==null&&S.key!=null?ye(""+S.key):L.toString(36)}function ce(S){switch(S.status){case"fulfilled":return S.value;case"rejected":throw S.reason;default:switch(typeof S.status=="string"?S.then(K,K):(S.status="pending",S.then(function(L){S.status==="pending"&&(S.status="fulfilled",S.value=L)},function(L){S.status==="pending"&&(S.status="rejected",S.reason=L)})),S.status){case"fulfilled":return S.value;case"rejected":throw S.reason}}throw S}function k(S,L,V,J,re){var fe=typeof S;(fe==="undefined"||fe==="boolean")&&(S=null);var oe=!1;if(S===null)oe=!0;else switch(fe){case"bigint":case"string":case"number":oe=!0;break;case"object":switch(S.$$typeof){case i:case c:oe=!0;break;case v:return oe=S._init,k(oe(S._payload),L,V,J,re)}}if(oe)return re=re(S),oe=J===""?"."+Qe(S,0):J,$(re)?(V="",oe!=null&&(V=oe.replace(Ge,"$&/")+"/"),k(re,L,V,"",function(at){return at})):re!=null&&(ae(re)&&(re=Q(re,V+(re.key==null||S&&S.key===re.key?"":(""+re.key).replace(Ge,"$&/")+"/")+oe)),L.push(re)),1;oe=0;var ze=J===""?".":J+":";if($(S))for(var Ee=0;Ee<S.length;Ee++)J=S[Ee],fe=ze+Qe(J,Ee),oe+=k(J,L,V,fe,re);else if(Ee=w(S),typeof Ee=="function")for(S=Ee.call(S),Ee=0;!(J=S.next()).done;)J=J.value,fe=ze+Qe(J,Ee++),oe+=k(J,L,V,fe,re);else if(fe==="object"){if(typeof S.then=="function")return k(ce(S),L,V,J,re);throw L=String(S),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(S).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return oe}function Y(S,L,V){if(S==null)return S;var J=[],re=0;return k(S,J,"","",function(fe){return L.call(V,fe,re++)}),J}function te(S){if(S._status===-1){var L=S._result;L=L(),L.then(function(V){(S._status===0||S._status===-1)&&(S._status=1,S._result=V)},function(V){(S._status===0||S._status===-1)&&(S._status=2,S._result=V)}),S._status===-1&&(S._status=0,S._result=L)}if(S._status===1)return S._result.default;throw S._result}var he=typeof reportError=="function"?reportError:function(S){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof S=="object"&&S!==null&&typeof S.message=="string"?String(S.message):String(S),error:S});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",S);return}console.error(S)},je={map:Y,forEach:function(S,L,V){Y(S,function(){L.apply(this,arguments)},V)},count:function(S){var L=0;return Y(S,function(){L++}),L},toArray:function(S){return Y(S,function(L){return L})||[]},only:function(S){if(!ae(S))throw Error("React.Children.only expected to receive a single React element child.");return S}};return ue.Activity=y,ue.Children=je,ue.Component=U,ue.Fragment=o,ue.Profiler=f,ue.PureComponent=Z,ue.StrictMode=u,ue.Suspense=g,ue.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=G,ue.__COMPILER_RUNTIME={__proto__:null,c:function(S){return G.H.useMemoCache(S)}},ue.cache=function(S){return function(){return S.apply(null,arguments)}},ue.cacheSignal=function(){return null},ue.cloneElement=function(S,L,V){if(S==null)throw Error("The argument must be a React element, but you passed "+S+".");var J=A({},S.props),re=S.key;if(L!=null)for(fe in L.key!==void 0&&(re=""+L.key),L)!ee.call(L,fe)||fe==="key"||fe==="__self"||fe==="__source"||fe==="ref"&&L.ref===void 0||(J[fe]=L[fe]);var fe=arguments.length-2;if(fe===1)J.children=V;else if(1<fe){for(var oe=Array(fe),ze=0;ze<fe;ze++)oe[ze]=arguments[ze+2];J.children=oe}return se(S.type,re,J)},ue.createContext=function(S){return S={$$typeof:h,_currentValue:S,_currentValue2:S,_threadCount:0,Provider:null,Consumer:null},S.Provider=S,S.Consumer={$$typeof:d,_context:S},S},ue.createElement=function(S,L,V){var J,re={},fe=null;if(L!=null)for(J in L.key!==void 0&&(fe=""+L.key),L)ee.call(L,J)&&J!=="key"&&J!=="__self"&&J!=="__source"&&(re[J]=L[J]);var oe=arguments.length-2;if(oe===1)re.children=V;else if(1<oe){for(var ze=Array(oe),Ee=0;Ee<oe;Ee++)ze[Ee]=arguments[Ee+2];re.children=ze}if(S&&S.defaultProps)for(J in oe=S.defaultProps,oe)re[J]===void 0&&(re[J]=oe[J]);return se(S,fe,re)},ue.createRef=function(){return{current:null}},ue.forwardRef=function(S){return{$$typeof:p,render:S}},ue.isValidElement=ae,ue.lazy=function(S){return{$$typeof:v,_payload:{_status:-1,_result:S},_init:te}},ue.memo=function(S,L){return{$$typeof:x,type:S,compare:L===void 0?null:L}},ue.startTransition=function(S){var L=G.T,V={};G.T=V;try{var J=S(),re=G.S;re!==null&&re(V,J),typeof J=="object"&&J!==null&&typeof J.then=="function"&&J.then(K,he)}catch(fe){he(fe)}finally{L!==null&&V.types!==null&&(L.types=V.types),G.T=L}},ue.unstable_useCacheRefresh=function(){return G.H.useCacheRefresh()},ue.use=function(S){return G.H.use(S)},ue.useActionState=function(S,L,V){return G.H.useActionState(S,L,V)},ue.useCallback=function(S,L){return G.H.useCallback(S,L)},ue.useContext=function(S){return G.H.useContext(S)},ue.useDebugValue=function(){},ue.useDeferredValue=function(S,L){return G.H.useDeferredValue(S,L)},ue.useEffect=function(S,L){return G.H.useEffect(S,L)},ue.useEffectEvent=function(S){return G.H.useEffectEvent(S)},ue.useId=function(){return G.H.useId()},ue.useImperativeHandle=function(S,L,V){return G.H.useImperativeHandle(S,L,V)},ue.useInsertionEffect=function(S,L){return G.H.useInsertionEffect(S,L)},ue.useLayoutEffect=function(S,L){return G.H.useLayoutEffect(S,L)},ue.useMemo=function(S,L){return G.H.useMemo(S,L)},ue.useOptimistic=function(S,L){return G.H.useOptimistic(S,L)},ue.useReducer=function(S,L,V){return G.H.useReducer(S,L,V)},ue.useRef=function(S){return G.H.useRef(S)},ue.useState=function(S){return G.H.useState(S)},ue.useSyncExternalStore=function(S,L,V){return G.H.useSyncExternalStore(S,L,V)},ue.useTransition=function(){return G.H.useTransition()},ue.version="19.2.8",ue}var yh;function Fo(){return yh||(yh=1,ko.exports=nb()),ko.exports}var q=Fo(),Oo={exports:{}},Es={},Do={exports:{}},Ro={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jh;function sb(){return jh||(jh=1,(function(i){function c(k,Y){var te=k.length;k.push(Y);e:for(;0<te;){var he=te-1>>>1,je=k[he];if(0<f(je,Y))k[he]=Y,k[te]=je,te=he;else break e}}function o(k){return k.length===0?null:k[0]}function u(k){if(k.length===0)return null;var Y=k[0],te=k.pop();if(te!==Y){k[0]=te;e:for(var he=0,je=k.length,S=je>>>1;he<S;){var L=2*(he+1)-1,V=k[L],J=L+1,re=k[J];if(0>f(V,te))J<je&&0>f(re,V)?(k[he]=re,k[J]=te,he=J):(k[he]=V,k[L]=te,he=L);else if(J<je&&0>f(re,te))k[he]=re,k[J]=te,he=J;else break e}}return Y}function f(k,Y){var te=k.sortIndex-Y.sortIndex;return te!==0?te:k.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;i.unstable_now=function(){return d.now()}}else{var h=Date,p=h.now();i.unstable_now=function(){return h.now()-p}}var g=[],x=[],v=1,y=null,z=3,w=!1,N=!1,A=!1,D=!1,U=typeof setTimeout=="function"?setTimeout:null,X=typeof clearTimeout=="function"?clearTimeout:null,Z=typeof setImmediate<"u"?setImmediate:null;function P(k){for(var Y=o(x);Y!==null;){if(Y.callback===null)u(x);else if(Y.startTime<=k)u(x),Y.sortIndex=Y.expirationTime,c(g,Y);else break;Y=o(x)}}function $(k){if(A=!1,P(k),!N)if(o(g)!==null)N=!0,K||(K=!0,ye());else{var Y=o(x);Y!==null&&ce($,Y.startTime-k)}}var K=!1,G=-1,ee=5,se=-1;function Q(){return D?!0:!(i.unstable_now()-se<ee)}function ae(){if(D=!1,K){var k=i.unstable_now();se=k;var Y=!0;try{e:{N=!1,A&&(A=!1,X(G),G=-1),w=!0;var te=z;try{t:{for(P(k),y=o(g);y!==null&&!(y.expirationTime>k&&Q());){var he=y.callback;if(typeof he=="function"){y.callback=null,z=y.priorityLevel;var je=he(y.expirationTime<=k);if(k=i.unstable_now(),typeof je=="function"){y.callback=je,P(k),Y=!0;break t}y===o(g)&&u(g),P(k)}else u(g);y=o(g)}if(y!==null)Y=!0;else{var S=o(x);S!==null&&ce($,S.startTime-k),Y=!1}}break e}finally{y=null,z=te,w=!1}Y=void 0}}finally{Y?ye():K=!1}}}var ye;if(typeof Z=="function")ye=function(){Z(ae)};else if(typeof MessageChannel<"u"){var Ge=new MessageChannel,Qe=Ge.port2;Ge.port1.onmessage=ae,ye=function(){Qe.postMessage(null)}}else ye=function(){U(ae,0)};function ce(k,Y){G=U(function(){k(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(k){k.callback=null},i.unstable_forceFrameRate=function(k){0>k||125<k?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):ee=0<k?Math.floor(1e3/k):5},i.unstable_getCurrentPriorityLevel=function(){return z},i.unstable_next=function(k){switch(z){case 1:case 2:case 3:var Y=3;break;default:Y=z}var te=z;z=Y;try{return k()}finally{z=te}},i.unstable_requestPaint=function(){D=!0},i.unstable_runWithPriority=function(k,Y){switch(k){case 1:case 2:case 3:case 4:case 5:break;default:k=3}var te=z;z=k;try{return Y()}finally{z=te}},i.unstable_scheduleCallback=function(k,Y,te){var he=i.unstable_now();switch(typeof te=="object"&&te!==null?(te=te.delay,te=typeof te=="number"&&0<te?he+te:he):te=he,k){case 1:var je=-1;break;case 2:je=250;break;case 5:je=1073741823;break;case 4:je=1e4;break;default:je=5e3}return je=te+je,k={id:v++,callback:Y,priorityLevel:k,startTime:te,expirationTime:je,sortIndex:-1},te>he?(k.sortIndex=te,c(x,k),o(g)===null&&k===o(x)&&(A?(X(G),G=-1):A=!0,ce($,te-he))):(k.sortIndex=je,c(g,k),N||w||(N=!0,K||(K=!0,ye()))),k},i.unstable_shouldYield=Q,i.unstable_wrapCallback=function(k){var Y=z;return function(){var te=z;z=Y;try{return k.apply(this,arguments)}finally{z=te}}}})(Ro)),Ro}var Nh;function ib(){return Nh||(Nh=1,Do.exports=sb()),Do.exports}var Uo={exports:{}},mt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sh;function cb(){if(Sh)return mt;Sh=1;var i=Fo();function c(g){var x="https://react.dev/errors/"+g;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var v=2;v<arguments.length;v++)x+="&args[]="+encodeURIComponent(arguments[v])}return"Minified React error #"+g+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function o(){}var u={d:{f:o,r:function(){throw Error(c(522))},D:o,C:o,L:o,m:o,X:o,S:o,M:o},p:0,findDOMNode:null},f=Symbol.for("react.portal");function d(g,x,v){var y=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:f,key:y==null?null:""+y,children:g,containerInfo:x,implementation:v}}var h=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function p(g,x){if(g==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return mt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=u,mt.createPortal=function(g,x){var v=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(c(299));return d(g,x,null,v)},mt.flushSync=function(g){var x=h.T,v=u.p;try{if(h.T=null,u.p=2,g)return g()}finally{h.T=x,u.p=v,u.d.f()}},mt.preconnect=function(g,x){typeof g=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,u.d.C(g,x))},mt.prefetchDNS=function(g){typeof g=="string"&&u.d.D(g)},mt.preinit=function(g,x){if(typeof g=="string"&&x&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin),z=typeof x.integrity=="string"?x.integrity:void 0,w=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;v==="style"?u.d.S(g,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:y,integrity:z,fetchPriority:w}):v==="script"&&u.d.X(g,{crossOrigin:y,integrity:z,fetchPriority:w,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},mt.preinitModule=function(g,x){if(typeof g=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var v=p(x.as,x.crossOrigin);u.d.M(g,{crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0})}}else x==null&&u.d.M(g)},mt.preload=function(g,x){if(typeof g=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin);u.d.L(g,v,{crossOrigin:y,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},mt.preloadModule=function(g,x){if(typeof g=="string")if(x){var v=p(x.as,x.crossOrigin);u.d.m(g,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0})}else u.d.m(g)},mt.requestFormReset=function(g){u.d.r(g)},mt.unstable_batchedUpdates=function(g,x){return g(x)},mt.useFormState=function(g,x,v){return h.H.useFormState(g,x,v)},mt.useFormStatus=function(){return h.H.useHostTransitionStatus()},mt.version="19.2.8",mt}var zh;function rb(){if(zh)return Uo.exports;zh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Uo.exports=cb(),Uo.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Eh;function ob(){if(Eh)return Es;Eh=1;var i=ib(),c=Fo(),o=rb();function u(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)t+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function f(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function d(e){var t=e,l=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do t=e,(t.flags&4098)!==0&&(l=t.return),e=t.return;while(e)}return t.tag===3?l:null}function h(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function p(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function g(e){if(d(e)!==e)throw Error(u(188))}function x(e){var t=e.alternate;if(!t){if(t=d(e),t===null)throw Error(u(188));return t!==e?null:e}for(var l=e,a=t;;){var s=l.return;if(s===null)break;var r=s.alternate;if(r===null){if(a=s.return,a!==null){l=a;continue}break}if(s.child===r.child){for(r=s.child;r;){if(r===l)return g(s),e;if(r===a)return g(s),t;r=r.sibling}throw Error(u(188))}if(l.return!==a.return)l=s,a=r;else{for(var m=!1,b=s.child;b;){if(b===l){m=!0,l=s,a=r;break}if(b===a){m=!0,a=s,l=r;break}b=b.sibling}if(!m){for(b=r.child;b;){if(b===l){m=!0,l=r,a=s;break}if(b===a){m=!0,a=r,l=s;break}b=b.sibling}if(!m)throw Error(u(189))}}if(l.alternate!==a)throw Error(u(190))}if(l.tag!==3)throw Error(u(188));return l.stateNode.current===l?e:t}function v(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=v(e),t!==null)return t;e=e.sibling}return null}var y=Object.assign,z=Symbol.for("react.element"),w=Symbol.for("react.transitional.element"),N=Symbol.for("react.portal"),A=Symbol.for("react.fragment"),D=Symbol.for("react.strict_mode"),U=Symbol.for("react.profiler"),X=Symbol.for("react.consumer"),Z=Symbol.for("react.context"),P=Symbol.for("react.forward_ref"),$=Symbol.for("react.suspense"),K=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),ee=Symbol.for("react.lazy"),se=Symbol.for("react.activity"),Q=Symbol.for("react.memo_cache_sentinel"),ae=Symbol.iterator;function ye(e){return e===null||typeof e!="object"?null:(e=ae&&e[ae]||e["@@iterator"],typeof e=="function"?e:null)}var Ge=Symbol.for("react.client.reference");function Qe(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===Ge?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case A:return"Fragment";case U:return"Profiler";case D:return"StrictMode";case $:return"Suspense";case K:return"SuspenseList";case se:return"Activity"}if(typeof e=="object")switch(e.$$typeof){case N:return"Portal";case Z:return e.displayName||"Context";case X:return(e._context.displayName||"Context")+".Consumer";case P:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case G:return t=e.displayName||null,t!==null?t:Qe(e.type)||"Memo";case ee:t=e._payload,e=e._init;try{return Qe(e(t))}catch{}}return null}var ce=Array.isArray,k=c.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Y=o.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,te={pending:!1,data:null,method:null,action:null},he=[],je=-1;function S(e){return{current:e}}function L(e){0>je||(e.current=he[je],he[je]=null,je--)}function V(e,t){je++,he[je]=e.current,e.current=t}var J=S(null),re=S(null),fe=S(null),oe=S(null);function ze(e,t){switch(V(fe,t),V(re,e),V(J,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Bm(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Bm(t),e=qm(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}L(J),V(J,e)}function Ee(){L(J),L(re),L(fe)}function at(e){e.memoizedState!==null&&V(oe,e);var t=J.current,l=qm(t,e.type);t!==l&&(V(re,e),V(J,l))}function Me(e){re.current===e&&(L(J),L(re)),oe.current===e&&(L(oe),ys._currentValue=te)}var I,Te;function ke(e){if(I===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);I=t&&t[1]||"",Te=-1<l.stack.indexOf(` + at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return` +`+I+e+Te}var tt=!1;function yt(e,t){if(!e||tt)return"";tt=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var B=function(){throw Error()};if(Object.defineProperty(B.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(B,[])}catch(O){var M=O}Reflect.construct(e,[],B)}else{try{B.call()}catch(O){M=O}e.call(B.prototype)}}else{try{throw Error()}catch(O){M=O}(B=e())&&typeof B.catch=="function"&&B.catch(function(){})}}catch(O){if(O&&M&&typeof O.stack=="string")return[O.stack,M.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var s=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");s&&s.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var r=a.DetermineComponentFrameRoot(),m=r[0],b=r[1];if(m&&b){var j=m.split(` +`),C=b.split(` +`);for(s=a=0;a<j.length&&!j[a].includes("DetermineComponentFrameRoot");)a++;for(;s<C.length&&!C[s].includes("DetermineComponentFrameRoot");)s++;if(a===j.length||s===C.length)for(a=j.length-1,s=C.length-1;1<=a&&0<=s&&j[a]!==C[s];)s--;for(;1<=a&&0<=s;a--,s--)if(j[a]!==C[s]){if(a!==1||s!==1)do if(a--,s--,0>s||j[a]!==C[s]){var R=` +`+j[a].replace(" at new "," at ");return e.displayName&&R.includes("<anonymous>")&&(R=R.replace("<anonymous>",e.displayName)),R}while(1<=a&&0<=s);break}}}finally{tt=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?ke(l):""}function Jt(e,t){switch(e.tag){case 26:case 27:case 5:return ke(e.type);case 16:return ke("Lazy");case 13:return e.child!==t&&t!==null?ke("Suspense Fallback"):ke("Suspense");case 19:return ke("SuspenseList");case 0:case 15:return yt(e.type,!1);case 11:return yt(e.type.render,!1);case 1:return yt(e.type,!0);case 31:return ke("Activity");default:return""}}function ll(e){try{var t="",l=null;do t+=Jt(e,l),l=e,e=e.return;while(e);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ht=Object.prototype.hasOwnProperty,Wt=i.unstable_scheduleCallback,hl=i.unstable_cancelCallback,An=i.unstable_shouldYield,_n=i.unstable_requestPaint,dt=i.unstable_now,$s=i.unstable_getCurrentPriorityLevel,Qs=i.unstable_ImmediatePriority,Cn=i.unstable_UserBlockingPriority,Da=i.unstable_NormalPriority,Mn=i.unstable_LowPriority,pl=i.unstable_IdlePriority,kn=i.log,On=i.unstable_setDisableYieldValue,al=null,pt=null;function nl(e){if(typeof kn=="function"&&On(e),pt&&typeof pt.setStrictMode=="function")try{pt.setStrictMode(al,e)}catch{}}var xt=Math.clz32?Math.clz32:Xs,xc=Math.log,gc=Math.LN2;function Xs(e){return e>>>=0,e===0?32:31-(xc(e)/gc|0)|0}var Ra=256,da=262144,ma=4194304;function sl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ua(e,t,l){var a=e.pendingLanes;if(a===0)return 0;var s=0,r=e.suspendedLanes,m=e.pingedLanes;e=e.warmLanes;var b=a&134217727;return b!==0?(a=b&~r,a!==0?s=sl(a):(m&=b,m!==0?s=sl(m):l||(l=b&~e,l!==0&&(s=sl(l))))):(b=a&~r,b!==0?s=sl(b):m!==0?s=sl(m):l||(l=a&~e,l!==0&&(s=sl(l)))),s===0?0:t!==0&&t!==s&&(t&r)===0&&(r=s&-s,l=t&-t,r>=l||r===32&&(l&4194048)!==0)?t:s}function Ul(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Vs(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Zs(){var e=ma;return ma<<=1,(ma&62914560)===0&&(ma=4194304),e}function bc(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function Dn(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Yp(e,t,l,a,s,r){var m=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var b=e.entanglements,j=e.expirationTimes,C=e.hiddenUpdates;for(l=m&~l;0<l;){var R=31-xt(l),B=1<<R;b[R]=0,j[R]=-1;var M=C[R];if(M!==null)for(C[R]=null,R=0;R<M.length;R++){var O=M[R];O!==null&&(O.lane&=-536870913)}l&=~B}a!==0&&yu(e,a,0),r!==0&&s===0&&e.tag!==0&&(e.suspendedLanes|=r&~(m&~t))}function yu(e,t,l){e.pendingLanes|=t,e.suspendedLanes&=~t;var a=31-xt(t);e.entangledLanes|=t,e.entanglements[a]=e.entanglements[a]|1073741824|l&261930}function ju(e,t){var l=e.entangledLanes|=t;for(e=e.entanglements;l;){var a=31-xt(l),s=1<<a;s&t|e[a]&t&&(e[a]|=t),l&=~s}}function Nu(e,t){var l=t&-t;return l=(l&42)!==0?1:vc(l),(l&(e.suspendedLanes|t))!==0?0:l}function vc(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function yc(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function Su(){var e=Y.p;return e!==0?e:(e=window.event,e===void 0?32:oh(e.type))}function zu(e,t){var l=Y.p;try{return Y.p=e,t()}finally{Y.p=l}}var Hl=Math.random().toString(36).slice(2),it="__reactFiber$"+Hl,jt="__reactProps$"+Hl,Ha="__reactContainer$"+Hl,jc="__reactEvents$"+Hl,Gp="__reactListeners$"+Hl,$p="__reactHandles$"+Hl,Eu="__reactResources$"+Hl,Rn="__reactMarker$"+Hl;function Nc(e){delete e[it],delete e[jt],delete e[jc],delete e[Gp],delete e[$p]}function La(e){var t=e[it];if(t)return t;for(var l=e.parentNode;l;){if(t=l[Ha]||l[it]){if(l=t.alternate,t.child!==null||l!==null&&l.child!==null)for(e=Zm(e);e!==null;){if(l=e[it])return l;e=Zm(e)}return t}e=l,l=e.parentNode}return null}function Ba(e){if(e=e[it]||e[Ha]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Un(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(u(33))}function qa(e){var t=e[Eu];return t||(t=e[Eu]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function nt(e){e[Rn]=!0}var wu=new Set,Tu={};function ha(e,t){Ya(e,t),Ya(e+"Capture",t)}function Ya(e,t){for(Tu[e]=t,e=0;e<t.length;e++)wu.add(t[e])}var Qp=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$"),Au={},_u={};function Xp(e){return Ht.call(_u,e)?!0:Ht.call(Au,e)?!1:Qp.test(e)?_u[e]=!0:(Au[e]=!0,!1)}function Ks(e,t,l){if(Xp(t))if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":e.removeAttribute(t);return;case"boolean":var a=t.toLowerCase().slice(0,5);if(a!=="data-"&&a!=="aria-"){e.removeAttribute(t);return}}e.setAttribute(t,""+l)}}function Js(e,t,l){if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(t);return}e.setAttribute(t,""+l)}}function xl(e,t,l,a){if(a===null)e.removeAttribute(l);else{switch(typeof a){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(l);return}e.setAttributeNS(t,l,""+a)}}function Lt(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Cu(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Vp(e,t,l){var a=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);if(!e.hasOwnProperty(t)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var s=a.get,r=a.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return s.call(this)},set:function(m){l=""+m,r.call(this,m)}}),Object.defineProperty(e,t,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(m){l=""+m},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Sc(e){if(!e._valueTracker){var t=Cu(e)?"checked":"value";e._valueTracker=Vp(e,t,""+e[t])}}function Mu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var l=t.getValue(),a="";return e&&(a=Cu(e)?e.checked?"true":"false":e.value),e=a,e!==l?(t.setValue(e),!0):!1}function Ws(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Zp=/[\n"\\]/g;function Bt(e){return e.replace(Zp,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function zc(e,t,l,a,s,r,m,b){e.name="",m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"?e.type=m:e.removeAttribute("type"),t!=null?m==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Lt(t)):e.value!==""+Lt(t)&&(e.value=""+Lt(t)):m!=="submit"&&m!=="reset"||e.removeAttribute("value"),t!=null?Ec(e,m,Lt(t)):l!=null?Ec(e,m,Lt(l)):a!=null&&e.removeAttribute("value"),s==null&&r!=null&&(e.defaultChecked=!!r),s!=null&&(e.checked=s&&typeof s!="function"&&typeof s!="symbol"),b!=null&&typeof b!="function"&&typeof b!="symbol"&&typeof b!="boolean"?e.name=""+Lt(b):e.removeAttribute("name")}function ku(e,t,l,a,s,r,m,b){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(e.type=r),t!=null||l!=null){if(!(r!=="submit"&&r!=="reset"||t!=null)){Sc(e);return}l=l!=null?""+Lt(l):"",t=t!=null?""+Lt(t):l,b||t===e.value||(e.value=t),e.defaultValue=t}a=a??s,a=typeof a!="function"&&typeof a!="symbol"&&!!a,e.checked=b?e.checked:!!a,e.defaultChecked=!!a,m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"&&(e.name=m),Sc(e)}function Ec(e,t,l){t==="number"&&Ws(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function Ga(e,t,l,a){if(e=e.options,t){t={};for(var s=0;s<l.length;s++)t["$"+l[s]]=!0;for(l=0;l<e.length;l++)s=t.hasOwnProperty("$"+e[l].value),e[l].selected!==s&&(e[l].selected=s),s&&a&&(e[l].defaultSelected=!0)}else{for(l=""+Lt(l),t=null,s=0;s<e.length;s++){if(e[s].value===l){e[s].selected=!0,a&&(e[s].defaultSelected=!0);return}t!==null||e[s].disabled||(t=e[s])}t!==null&&(t.selected=!0)}}function Ou(e,t,l){if(t!=null&&(t=""+Lt(t),t!==e.value&&(e.value=t),l==null)){e.defaultValue!==t&&(e.defaultValue=t);return}e.defaultValue=l!=null?""+Lt(l):""}function Du(e,t,l,a){if(t==null){if(a!=null){if(l!=null)throw Error(u(92));if(ce(a)){if(1<a.length)throw Error(u(93));a=a[0]}l=a}l==null&&(l=""),t=l}l=Lt(t),e.defaultValue=l,a=e.textContent,a===l&&a!==""&&a!==null&&(e.value=a),Sc(e)}function $a(e,t){if(t){var l=e.firstChild;if(l&&l===e.lastChild&&l.nodeType===3){l.nodeValue=t;return}}e.textContent=t}var Kp=new Set("animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp".split(" "));function Ru(e,t,l){var a=t.indexOf("--")===0;l==null||typeof l=="boolean"||l===""?a?e.setProperty(t,""):t==="float"?e.cssFloat="":e[t]="":a?e.setProperty(t,l):typeof l!="number"||l===0||Kp.has(t)?t==="float"?e.cssFloat=l:e[t]=(""+l).trim():e[t]=l+"px"}function Uu(e,t,l){if(t!=null&&typeof t!="object")throw Error(u(62));if(e=e.style,l!=null){for(var a in l)!l.hasOwnProperty(a)||t!=null&&t.hasOwnProperty(a)||(a.indexOf("--")===0?e.setProperty(a,""):a==="float"?e.cssFloat="":e[a]="");for(var s in t)a=t[s],t.hasOwnProperty(s)&&l[s]!==a&&Ru(e,s,a)}else for(var r in t)t.hasOwnProperty(r)&&Ru(e,r,t[r])}function wc(e){if(e.indexOf("-")===-1)return!1;switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Jp=new Map([["acceptCharset","accept-charset"],["htmlFor","for"],["httpEquiv","http-equiv"],["crossOrigin","crossorigin"],["accentHeight","accent-height"],["alignmentBaseline","alignment-baseline"],["arabicForm","arabic-form"],["baselineShift","baseline-shift"],["capHeight","cap-height"],["clipPath","clip-path"],["clipRule","clip-rule"],["colorInterpolation","color-interpolation"],["colorInterpolationFilters","color-interpolation-filters"],["colorProfile","color-profile"],["colorRendering","color-rendering"],["dominantBaseline","dominant-baseline"],["enableBackground","enable-background"],["fillOpacity","fill-opacity"],["fillRule","fill-rule"],["floodColor","flood-color"],["floodOpacity","flood-opacity"],["fontFamily","font-family"],["fontSize","font-size"],["fontSizeAdjust","font-size-adjust"],["fontStretch","font-stretch"],["fontStyle","font-style"],["fontVariant","font-variant"],["fontWeight","font-weight"],["glyphName","glyph-name"],["glyphOrientationHorizontal","glyph-orientation-horizontal"],["glyphOrientationVertical","glyph-orientation-vertical"],["horizAdvX","horiz-adv-x"],["horizOriginX","horiz-origin-x"],["imageRendering","image-rendering"],["letterSpacing","letter-spacing"],["lightingColor","lighting-color"],["markerEnd","marker-end"],["markerMid","marker-mid"],["markerStart","marker-start"],["overlinePosition","overline-position"],["overlineThickness","overline-thickness"],["paintOrder","paint-order"],["panose-1","panose-1"],["pointerEvents","pointer-events"],["renderingIntent","rendering-intent"],["shapeRendering","shape-rendering"],["stopColor","stop-color"],["stopOpacity","stop-opacity"],["strikethroughPosition","strikethrough-position"],["strikethroughThickness","strikethrough-thickness"],["strokeDasharray","stroke-dasharray"],["strokeDashoffset","stroke-dashoffset"],["strokeLinecap","stroke-linecap"],["strokeLinejoin","stroke-linejoin"],["strokeMiterlimit","stroke-miterlimit"],["strokeOpacity","stroke-opacity"],["strokeWidth","stroke-width"],["textAnchor","text-anchor"],["textDecoration","text-decoration"],["textRendering","text-rendering"],["transformOrigin","transform-origin"],["underlinePosition","underline-position"],["underlineThickness","underline-thickness"],["unicodeBidi","unicode-bidi"],["unicodeRange","unicode-range"],["unitsPerEm","units-per-em"],["vAlphabetic","v-alphabetic"],["vHanging","v-hanging"],["vIdeographic","v-ideographic"],["vMathematical","v-mathematical"],["vectorEffect","vector-effect"],["vertAdvY","vert-adv-y"],["vertOriginX","vert-origin-x"],["vertOriginY","vert-origin-y"],["wordSpacing","word-spacing"],["writingMode","writing-mode"],["xmlnsXlink","xmlns:xlink"],["xHeight","x-height"]]),Wp=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;function Fs(e){return Wp.test(""+e)?"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')":e}function gl(){}var Tc=null;function Ac(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Qa=null,Xa=null;function Hu(e){var t=Ba(e);if(t&&(e=t.stateNode)){var l=e[jt]||null;e:switch(e=t.stateNode,t.type){case"input":if(zc(e,l.value,l.defaultValue,l.defaultValue,l.checked,l.defaultChecked,l.type,l.name),t=l.name,l.type==="radio"&&t!=null){for(l=e;l.parentNode;)l=l.parentNode;for(l=l.querySelectorAll('input[name="'+Bt(""+t)+'"][type="radio"]'),t=0;t<l.length;t++){var a=l[t];if(a!==e&&a.form===e.form){var s=a[jt]||null;if(!s)throw Error(u(90));zc(a,s.value,s.defaultValue,s.defaultValue,s.checked,s.defaultChecked,s.type,s.name)}}for(t=0;t<l.length;t++)a=l[t],a.form===e.form&&Mu(a)}break e;case"textarea":Ou(e,l.value,l.defaultValue);break e;case"select":t=l.value,t!=null&&Ga(e,!!l.multiple,t,!1)}}}var _c=!1;function Lu(e,t,l){if(_c)return e(t,l);_c=!0;try{var a=e(t);return a}finally{if(_c=!1,(Qa!==null||Xa!==null)&&(Li(),Qa&&(t=Qa,e=Xa,Xa=Qa=null,Hu(t),e)))for(t=0;t<e.length;t++)Hu(e[t])}}function Hn(e,t){var l=e.stateNode;if(l===null)return null;var a=l[jt]||null;if(a===null)return null;l=a[t];e:switch(t){case"onClick":case"onClickCapture":case"onDoubleClick":case"onDoubleClickCapture":case"onMouseDown":case"onMouseDownCapture":case"onMouseMove":case"onMouseMoveCapture":case"onMouseUp":case"onMouseUpCapture":case"onMouseEnter":(a=!a.disabled)||(e=e.type,a=!(e==="button"||e==="input"||e==="select"||e==="textarea")),e=!a;break e;default:e=!1}if(e)return null;if(l&&typeof l!="function")throw Error(u(231,t,typeof l));return l}var bl=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Cc=!1;if(bl)try{var Ln={};Object.defineProperty(Ln,"passive",{get:function(){Cc=!0}}),window.addEventListener("test",Ln,Ln),window.removeEventListener("test",Ln,Ln)}catch{Cc=!1}var Ll=null,Mc=null,Is=null;function Bu(){if(Is)return Is;var e,t=Mc,l=t.length,a,s="value"in Ll?Ll.value:Ll.textContent,r=s.length;for(e=0;e<l&&t[e]===s[e];e++);var m=l-e;for(a=1;a<=m&&t[l-a]===s[r-a];a++);return Is=s.slice(e,1<a?1-a:void 0)}function Ps(e){var t=e.keyCode;return"charCode"in e?(e=e.charCode,e===0&&t===13&&(e=13)):e=t,e===10&&(e=13),32<=e||e===13?e:0}function ei(){return!0}function qu(){return!1}function Nt(e){function t(l,a,s,r,m){this._reactName=l,this._targetInst=s,this.type=a,this.nativeEvent=r,this.target=m,this.currentTarget=null;for(var b in e)e.hasOwnProperty(b)&&(l=e[b],this[b]=l?l(r):r[b]);return this.isDefaultPrevented=(r.defaultPrevented!=null?r.defaultPrevented:r.returnValue===!1)?ei:qu,this.isPropagationStopped=qu,this}return y(t.prototype,{preventDefault:function(){this.defaultPrevented=!0;var l=this.nativeEvent;l&&(l.preventDefault?l.preventDefault():typeof l.returnValue!="unknown"&&(l.returnValue=!1),this.isDefaultPrevented=ei)},stopPropagation:function(){var l=this.nativeEvent;l&&(l.stopPropagation?l.stopPropagation():typeof l.cancelBubble!="unknown"&&(l.cancelBubble=!0),this.isPropagationStopped=ei)},persist:function(){},isPersistent:ei}),t}var pa={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},ti=Nt(pa),Bn=y({},pa,{view:0,detail:0}),Fp=Nt(Bn),kc,Oc,qn,li=y({},Bn,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Rc,button:0,buttons:0,relatedTarget:function(e){return e.relatedTarget===void 0?e.fromElement===e.srcElement?e.toElement:e.fromElement:e.relatedTarget},movementX:function(e){return"movementX"in e?e.movementX:(e!==qn&&(qn&&e.type==="mousemove"?(kc=e.screenX-qn.screenX,Oc=e.screenY-qn.screenY):Oc=kc=0,qn=e),kc)},movementY:function(e){return"movementY"in e?e.movementY:Oc}}),Yu=Nt(li),Ip=y({},li,{dataTransfer:0}),Pp=Nt(Ip),ex=y({},Bn,{relatedTarget:0}),Dc=Nt(ex),tx=y({},pa,{animationName:0,elapsedTime:0,pseudoElement:0}),lx=Nt(tx),ax=y({},pa,{clipboardData:function(e){return"clipboardData"in e?e.clipboardData:window.clipboardData}}),nx=Nt(ax),sx=y({},pa,{data:0}),Gu=Nt(sx),ix={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},cx={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},rx={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"};function ox(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):(e=rx[e])?!!t[e]:!1}function Rc(){return ox}var ux=y({},Bn,{key:function(e){if(e.key){var t=ix[e.key]||e.key;if(t!=="Unidentified")return t}return e.type==="keypress"?(e=Ps(e),e===13?"Enter":String.fromCharCode(e)):e.type==="keydown"||e.type==="keyup"?cx[e.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:Rc,charCode:function(e){return e.type==="keypress"?Ps(e):0},keyCode:function(e){return e.type==="keydown"||e.type==="keyup"?e.keyCode:0},which:function(e){return e.type==="keypress"?Ps(e):e.type==="keydown"||e.type==="keyup"?e.keyCode:0}}),fx=Nt(ux),dx=y({},li,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),$u=Nt(dx),mx=y({},Bn,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:Rc}),hx=Nt(mx),px=y({},pa,{propertyName:0,elapsedTime:0,pseudoElement:0}),xx=Nt(px),gx=y({},li,{deltaX:function(e){return"deltaX"in e?e.deltaX:"wheelDeltaX"in e?-e.wheelDeltaX:0},deltaY:function(e){return"deltaY"in e?e.deltaY:"wheelDeltaY"in e?-e.wheelDeltaY:"wheelDelta"in e?-e.wheelDelta:0},deltaZ:0,deltaMode:0}),bx=Nt(gx),vx=y({},pa,{newState:0,oldState:0}),yx=Nt(vx),jx=[9,13,27,32],Uc=bl&&"CompositionEvent"in window,Yn=null;bl&&"documentMode"in document&&(Yn=document.documentMode);var Nx=bl&&"TextEvent"in window&&!Yn,Qu=bl&&(!Uc||Yn&&8<Yn&&11>=Yn),Xu=" ",Vu=!1;function Zu(e,t){switch(e){case"keyup":return jx.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ku(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Va=!1;function Sx(e,t){switch(e){case"compositionend":return Ku(t);case"keypress":return t.which!==32?null:(Vu=!0,Xu);case"textInput":return e=t.data,e===Xu&&Vu?null:e;default:return null}}function zx(e,t){if(Va)return e==="compositionend"||!Uc&&Zu(e,t)?(e=Bu(),Is=Mc=Ll=null,Va=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case"compositionend":return Qu&&t.locale!=="ko"?null:t.data;default:return null}}var Ex={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Ju(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t==="input"?!!Ex[e.type]:t==="textarea"}function Wu(e,t,l,a){Qa?Xa?Xa.push(a):Xa=[a]:Qa=a,t=Xi(t,"onChange"),0<t.length&&(l=new ti("onChange","change",null,l,a),e.push({event:l,listeners:t}))}var Gn=null,$n=null;function wx(e){Om(e,0)}function ai(e){var t=Un(e);if(Mu(t))return e}function Fu(e,t){if(e==="change")return t}var Iu=!1;if(bl){var Hc;if(bl){var Lc="oninput"in document;if(!Lc){var Pu=document.createElement("div");Pu.setAttribute("oninput","return;"),Lc=typeof Pu.oninput=="function"}Hc=Lc}else Hc=!1;Iu=Hc&&(!document.documentMode||9<document.documentMode)}function ef(){Gn&&(Gn.detachEvent("onpropertychange",tf),$n=Gn=null)}function tf(e){if(e.propertyName==="value"&&ai($n)){var t=[];Wu(t,$n,e,Ac(e)),Lu(wx,t)}}function Tx(e,t,l){e==="focusin"?(ef(),Gn=t,$n=l,Gn.attachEvent("onpropertychange",tf)):e==="focusout"&&ef()}function Ax(e){if(e==="selectionchange"||e==="keyup"||e==="keydown")return ai($n)}function _x(e,t){if(e==="click")return ai(t)}function Cx(e,t){if(e==="input"||e==="change")return ai(t)}function Mx(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Ct=typeof Object.is=="function"?Object.is:Mx;function Qn(e,t){if(Ct(e,t))return!0;if(typeof e!="object"||e===null||typeof t!="object"||t===null)return!1;var l=Object.keys(e),a=Object.keys(t);if(l.length!==a.length)return!1;for(a=0;a<l.length;a++){var s=l[a];if(!Ht.call(t,s)||!Ct(e[s],t[s]))return!1}return!0}function lf(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function af(e,t){var l=lf(e);e=0;for(var a;l;){if(l.nodeType===3){if(a=e+l.textContent.length,e<=t&&a>=t)return{node:l,offset:t-e};e=a}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=lf(l)}}function nf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?nf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function sf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ws(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=Ws(e.document)}return t}function Bc(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var kx=bl&&"documentMode"in document&&11>=document.documentMode,Za=null,qc=null,Xn=null,Yc=!1;function cf(e,t,l){var a=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Yc||Za==null||Za!==Ws(a)||(a=Za,"selectionStart"in a&&Bc(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Xn&&Qn(Xn,a)||(Xn=a,a=Xi(qc,"onSelect"),0<a.length&&(t=new ti("onSelect","select",null,t,l),e.push({event:t,listeners:a}),t.target=Za)))}function xa(e,t){var l={};return l[e.toLowerCase()]=t.toLowerCase(),l["Webkit"+e]="webkit"+t,l["Moz"+e]="moz"+t,l}var Ka={animationend:xa("Animation","AnimationEnd"),animationiteration:xa("Animation","AnimationIteration"),animationstart:xa("Animation","AnimationStart"),transitionrun:xa("Transition","TransitionRun"),transitionstart:xa("Transition","TransitionStart"),transitioncancel:xa("Transition","TransitionCancel"),transitionend:xa("Transition","TransitionEnd")},Gc={},rf={};bl&&(rf=document.createElement("div").style,"AnimationEvent"in window||(delete Ka.animationend.animation,delete Ka.animationiteration.animation,delete Ka.animationstart.animation),"TransitionEvent"in window||delete Ka.transitionend.transition);function ga(e){if(Gc[e])return Gc[e];if(!Ka[e])return e;var t=Ka[e],l;for(l in t)if(t.hasOwnProperty(l)&&l in rf)return Gc[e]=t[l];return e}var of=ga("animationend"),uf=ga("animationiteration"),ff=ga("animationstart"),Ox=ga("transitionrun"),Dx=ga("transitionstart"),Rx=ga("transitioncancel"),df=ga("transitionend"),mf=new Map,$c="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");$c.push("scrollEnd");function Ft(e,t){mf.set(e,t),ha(t,[e])}var ni=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)},qt=[],Ja=0,Qc=0;function si(){for(var e=Ja,t=Qc=Ja=0;t<e;){var l=qt[t];qt[t++]=null;var a=qt[t];qt[t++]=null;var s=qt[t];qt[t++]=null;var r=qt[t];if(qt[t++]=null,a!==null&&s!==null){var m=a.pending;m===null?s.next=s:(s.next=m.next,m.next=s),a.pending=s}r!==0&&hf(l,s,r)}}function ii(e,t,l,a){qt[Ja++]=e,qt[Ja++]=t,qt[Ja++]=l,qt[Ja++]=a,Qc|=a,e.lanes|=a,e=e.alternate,e!==null&&(e.lanes|=a)}function Xc(e,t,l,a){return ii(e,t,l,a),ci(e)}function ba(e,t){return ii(e,null,null,t),ci(e)}function hf(e,t,l){e.lanes|=l;var a=e.alternate;a!==null&&(a.lanes|=l);for(var s=!1,r=e.return;r!==null;)r.childLanes|=l,a=r.alternate,a!==null&&(a.childLanes|=l),r.tag===22&&(e=r.stateNode,e===null||e._visibility&1||(s=!0)),e=r,r=r.return;return e.tag===3?(r=e.stateNode,s&&t!==null&&(s=31-xt(l),e=r.hiddenUpdates,a=e[s],a===null?e[s]=[t]:a.push(t),t.lane=l|536870912),r):null}function ci(e){if(50<ms)throw ms=0,eo=null,Error(u(185));for(var t=e.return;t!==null;)e=t,t=e.return;return e.tag===3?e.stateNode:null}var Wa={};function Ux(e,t,l,a){this.tag=e,this.key=l,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Mt(e,t,l,a){return new Ux(e,t,l,a)}function Vc(e){return e=e.prototype,!(!e||!e.isReactComponent)}function vl(e,t){var l=e.alternate;return l===null?(l=Mt(e.tag,t,e.key,e.mode),l.elementType=e.elementType,l.type=e.type,l.stateNode=e.stateNode,l.alternate=e,e.alternate=l):(l.pendingProps=t,l.type=e.type,l.flags=0,l.subtreeFlags=0,l.deletions=null),l.flags=e.flags&65011712,l.childLanes=e.childLanes,l.lanes=e.lanes,l.child=e.child,l.memoizedProps=e.memoizedProps,l.memoizedState=e.memoizedState,l.updateQueue=e.updateQueue,t=e.dependencies,l.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},l.sibling=e.sibling,l.index=e.index,l.ref=e.ref,l.refCleanup=e.refCleanup,l}function pf(e,t){e.flags&=65011714;var l=e.alternate;return l===null?(e.childLanes=0,e.lanes=t,e.child=null,e.subtreeFlags=0,e.memoizedProps=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.stateNode=null):(e.childLanes=l.childLanes,e.lanes=l.lanes,e.child=l.child,e.subtreeFlags=0,e.deletions=null,e.memoizedProps=l.memoizedProps,e.memoizedState=l.memoizedState,e.updateQueue=l.updateQueue,e.type=l.type,t=l.dependencies,e.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext}),e}function ri(e,t,l,a,s,r){var m=0;if(a=e,typeof e=="function")Vc(e)&&(m=1);else if(typeof e=="string")m=Yg(e,l,J.current)?26:e==="html"||e==="head"||e==="body"?27:5;else e:switch(e){case se:return e=Mt(31,l,t,s),e.elementType=se,e.lanes=r,e;case A:return va(l.children,s,r,t);case D:m=8,s|=24;break;case U:return e=Mt(12,l,t,s|2),e.elementType=U,e.lanes=r,e;case $:return e=Mt(13,l,t,s),e.elementType=$,e.lanes=r,e;case K:return e=Mt(19,l,t,s),e.elementType=K,e.lanes=r,e;default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Z:m=10;break e;case X:m=9;break e;case P:m=11;break e;case G:m=14;break e;case ee:m=16,a=null;break e}m=29,l=Error(u(130,e===null?"null":typeof e,"")),a=null}return t=Mt(m,l,t,s),t.elementType=e,t.type=a,t.lanes=r,t}function va(e,t,l,a){return e=Mt(7,e,a,t),e.lanes=l,e}function Zc(e,t,l){return e=Mt(6,e,null,t),e.lanes=l,e}function xf(e){var t=Mt(18,null,null,0);return t.stateNode=e,t}function Kc(e,t,l){return t=Mt(4,e.children!==null?e.children:[],e.key,t),t.lanes=l,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}var gf=new WeakMap;function Yt(e,t){if(typeof e=="object"&&e!==null){var l=gf.get(e);return l!==void 0?l:(t={value:e,source:t,stack:ll(t)},gf.set(e,t),t)}return{value:e,source:t,stack:ll(t)}}var Fa=[],Ia=0,oi=null,Vn=0,Gt=[],$t=0,Bl=null,il=1,cl="";function yl(e,t){Fa[Ia++]=Vn,Fa[Ia++]=oi,oi=e,Vn=t}function bf(e,t,l){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,Bl=e;var a=il;e=cl;var s=32-xt(a)-1;a&=~(1<<s),l+=1;var r=32-xt(t)+s;if(30<r){var m=s-s%5;r=(a&(1<<m)-1).toString(32),a>>=m,s-=m,il=1<<32-xt(t)+s|l<<s|a,cl=r+e}else il=1<<r|l<<s|a,cl=e}function Jc(e){e.return!==null&&(yl(e,1),bf(e,1,0))}function Wc(e){for(;e===oi;)oi=Fa[--Ia],Fa[Ia]=null,Vn=Fa[--Ia],Fa[Ia]=null;for(;e===Bl;)Bl=Gt[--$t],Gt[$t]=null,cl=Gt[--$t],Gt[$t]=null,il=Gt[--$t],Gt[$t]=null}function vf(e,t){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,il=t.id,cl=t.overflow,Bl=e}var ct=null,Le=null,ve=!1,ql=null,Qt=!1,Fc=Error(u(519));function Yl(e){var t=Error(u(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?"text":"HTML",""));throw Zn(Yt(t,e)),Fc}function yf(e){var t=e.stateNode,l=e.type,a=e.memoizedProps;switch(t[it]=e,t[jt]=a,l){case"dialog":xe("cancel",t),xe("close",t);break;case"iframe":case"object":case"embed":xe("load",t);break;case"video":case"audio":for(l=0;l<ps.length;l++)xe(ps[l],t);break;case"source":xe("error",t);break;case"img":case"image":case"link":xe("error",t),xe("load",t);break;case"details":xe("toggle",t);break;case"input":xe("invalid",t),ku(t,a.value,a.defaultValue,a.checked,a.defaultChecked,a.type,a.name,!0);break;case"select":xe("invalid",t);break;case"textarea":xe("invalid",t),Du(t,a.value,a.defaultValue,a.children)}l=a.children,typeof l!="string"&&typeof l!="number"&&typeof l!="bigint"||t.textContent===""+l||a.suppressHydrationWarning===!0||Hm(t.textContent,l)?(a.popover!=null&&(xe("beforetoggle",t),xe("toggle",t)),a.onScroll!=null&&xe("scroll",t),a.onScrollEnd!=null&&xe("scrollend",t),a.onClick!=null&&(t.onclick=gl),t=!0):t=!1,t||Yl(e,!0)}function jf(e){for(ct=e.return;ct;)switch(ct.tag){case 5:case 31:case 13:Qt=!1;return;case 27:case 3:Qt=!0;return;default:ct=ct.return}}function Pa(e){if(e!==ct)return!1;if(!ve)return jf(e),ve=!0,!1;var t=e.tag,l;if((l=t!==3&&t!==27)&&((l=t===5)&&(l=e.type,l=!(l!=="form"&&l!=="button")||xo(e.type,e.memoizedProps)),l=!l),l&&Le&&Yl(e),jf(e),t===13){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else if(t===31){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else t===27?(t=Le,ta(e.type)?(e=jo,jo=null,Le=e):Le=t):Le=ct?Vt(e.stateNode.nextSibling):null;return!0}function ya(){Le=ct=null,ve=!1}function Ic(){var e=ql;return e!==null&&(wt===null?wt=e:wt.push.apply(wt,e),ql=null),e}function Zn(e){ql===null?ql=[e]:ql.push(e)}var Pc=S(null),ja=null,jl=null;function Gl(e,t,l){V(Pc,t._currentValue),t._currentValue=l}function Nl(e){e._currentValue=Pc.current,L(Pc)}function er(e,t,l){for(;e!==null;){var a=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,a!==null&&(a.childLanes|=t)):a!==null&&(a.childLanes&t)!==t&&(a.childLanes|=t),e===l)break;e=e.return}}function tr(e,t,l,a){var s=e.child;for(s!==null&&(s.return=e);s!==null;){var r=s.dependencies;if(r!==null){var m=s.child;r=r.firstContext;e:for(;r!==null;){var b=r;r=s;for(var j=0;j<t.length;j++)if(b.context===t[j]){r.lanes|=l,b=r.alternate,b!==null&&(b.lanes|=l),er(r.return,l,e),a||(m=null);break e}r=b.next}}else if(s.tag===18){if(m=s.return,m===null)throw Error(u(341));m.lanes|=l,r=m.alternate,r!==null&&(r.lanes|=l),er(m,l,e),m=null}else m=s.child;if(m!==null)m.return=s;else for(m=s;m!==null;){if(m===e){m=null;break}if(s=m.sibling,s!==null){s.return=m.return,m=s;break}m=m.return}s=m}}function en(e,t,l,a){e=null;for(var s=t,r=!1;s!==null;){if(!r){if((s.flags&524288)!==0)r=!0;else if((s.flags&262144)!==0)break}if(s.tag===10){var m=s.alternate;if(m===null)throw Error(u(387));if(m=m.memoizedProps,m!==null){var b=s.type;Ct(s.pendingProps.value,m.value)||(e!==null?e.push(b):e=[b])}}else if(s===oe.current){if(m=s.alternate,m===null)throw Error(u(387));m.memoizedState.memoizedState!==s.memoizedState.memoizedState&&(e!==null?e.push(ys):e=[ys])}s=s.return}e!==null&&tr(t,e,l,a),t.flags|=262144}function ui(e){for(e=e.firstContext;e!==null;){if(!Ct(e.context._currentValue,e.memoizedValue))return!0;e=e.next}return!1}function Na(e){ja=e,jl=null,e=e.dependencies,e!==null&&(e.firstContext=null)}function rt(e){return Nf(ja,e)}function fi(e,t){return ja===null&&Na(e),Nf(e,t)}function Nf(e,t){var l=t._currentValue;if(t={context:t,memoizedValue:l,next:null},jl===null){if(e===null)throw Error(u(308));jl=t,e.dependencies={lanes:0,firstContext:t},e.flags|=524288}else jl=jl.next=t;return l}var Hx=typeof AbortController<"u"?AbortController:function(){var e=[],t=this.signal={aborted:!1,addEventListener:function(l,a){e.push(a)}};this.abort=function(){t.aborted=!0,e.forEach(function(l){return l()})}},Lx=i.unstable_scheduleCallback,Bx=i.unstable_NormalPriority,Fe={$$typeof:Z,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function lr(){return{controller:new Hx,data:new Map,refCount:0}}function Kn(e){e.refCount--,e.refCount===0&&Lx(Bx,function(){e.controller.abort()})}var Jn=null,ar=0,tn=0,ln=null;function qx(e,t){if(Jn===null){var l=Jn=[];ar=0,tn=io(),ln={status:"pending",value:void 0,then:function(a){l.push(a)}}}return ar++,t.then(Sf,Sf),t}function Sf(){if(--ar===0&&Jn!==null){ln!==null&&(ln.status="fulfilled");var e=Jn;Jn=null,tn=0,ln=null;for(var t=0;t<e.length;t++)(0,e[t])()}}function Yx(e,t){var l=[],a={status:"pending",value:null,reason:null,then:function(s){l.push(s)}};return e.then(function(){a.status="fulfilled",a.value=t;for(var s=0;s<l.length;s++)(0,l[s])(t)},function(s){for(a.status="rejected",a.reason=s,s=0;s<l.length;s++)(0,l[s])(void 0)}),a}var zf=k.S;k.S=function(e,t){cm=dt(),typeof t=="object"&&t!==null&&typeof t.then=="function"&&qx(e,t),zf!==null&&zf(e,t)};var Sa=S(null);function nr(){var e=Sa.current;return e!==null?e:Ue.pooledCache}function di(e,t){t===null?V(Sa,Sa.current):V(Sa,t.pool)}function Ef(){var e=nr();return e===null?null:{parent:Fe._currentValue,pool:e}}var an=Error(u(460)),sr=Error(u(474)),mi=Error(u(542)),hi={then:function(){}};function wf(e){return e=e.status,e==="fulfilled"||e==="rejected"}function Tf(e,t,l){switch(l=e[l],l===void 0?e.push(t):l!==t&&(t.then(gl,gl),t=l),t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,_f(e),e;default:if(typeof t.status=="string")t.then(gl,gl);else{if(e=Ue,e!==null&&100<e.shellSuspendCounter)throw Error(u(482));e=t,e.status="pending",e.then(function(a){if(t.status==="pending"){var s=t;s.status="fulfilled",s.value=a}},function(a){if(t.status==="pending"){var s=t;s.status="rejected",s.reason=a}})}switch(t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,_f(e),e}throw Ea=t,an}}function za(e){try{var t=e._init;return t(e._payload)}catch(l){throw l!==null&&typeof l=="object"&&typeof l.then=="function"?(Ea=l,an):l}}var Ea=null;function Af(){if(Ea===null)throw Error(u(459));var e=Ea;return Ea=null,e}function _f(e){if(e===an||e===mi)throw Error(u(483))}var nn=null,Wn=0;function pi(e){var t=Wn;return Wn+=1,nn===null&&(nn=[]),Tf(nn,e,t)}function Fn(e,t){t=t.props.ref,e.ref=t!==void 0?t:null}function xi(e,t){throw t.$$typeof===z?Error(u(525)):(e=Object.prototype.toString.call(t),Error(u(31,e==="[object Object]"?"object with keys {"+Object.keys(t).join(", ")+"}":e)))}function Cf(e){function t(T,E){if(e){var _=T.deletions;_===null?(T.deletions=[E],T.flags|=16):_.push(E)}}function l(T,E){if(!e)return null;for(;E!==null;)t(T,E),E=E.sibling;return null}function a(T){for(var E=new Map;T!==null;)T.key!==null?E.set(T.key,T):E.set(T.index,T),T=T.sibling;return E}function s(T,E){return T=vl(T,E),T.index=0,T.sibling=null,T}function r(T,E,_){return T.index=_,e?(_=T.alternate,_!==null?(_=_.index,_<E?(T.flags|=67108866,E):_):(T.flags|=67108866,E)):(T.flags|=1048576,E)}function m(T){return e&&T.alternate===null&&(T.flags|=67108866),T}function b(T,E,_,H){return E===null||E.tag!==6?(E=Zc(_,T.mode,H),E.return=T,E):(E=s(E,_),E.return=T,E)}function j(T,E,_,H){var ne=_.type;return ne===A?R(T,E,_.props.children,H,_.key):E!==null&&(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type)?(E=s(E,_.props),Fn(E,_),E.return=T,E):(E=ri(_.type,_.key,_.props,null,T.mode,H),Fn(E,_),E.return=T,E)}function C(T,E,_,H){return E===null||E.tag!==4||E.stateNode.containerInfo!==_.containerInfo||E.stateNode.implementation!==_.implementation?(E=Kc(_,T.mode,H),E.return=T,E):(E=s(E,_.children||[]),E.return=T,E)}function R(T,E,_,H,ne){return E===null||E.tag!==7?(E=va(_,T.mode,H,ne),E.return=T,E):(E=s(E,_),E.return=T,E)}function B(T,E,_){if(typeof E=="string"&&E!==""||typeof E=="number"||typeof E=="bigint")return E=Zc(""+E,T.mode,_),E.return=T,E;if(typeof E=="object"&&E!==null){switch(E.$$typeof){case w:return _=ri(E.type,E.key,E.props,null,T.mode,_),Fn(_,E),_.return=T,_;case N:return E=Kc(E,T.mode,_),E.return=T,E;case ee:return E=za(E),B(T,E,_)}if(ce(E)||ye(E))return E=va(E,T.mode,_,null),E.return=T,E;if(typeof E.then=="function")return B(T,pi(E),_);if(E.$$typeof===Z)return B(T,fi(T,E),_);xi(T,E)}return null}function M(T,E,_,H){var ne=E!==null?E.key:null;if(typeof _=="string"&&_!==""||typeof _=="number"||typeof _=="bigint")return ne!==null?null:b(T,E,""+_,H);if(typeof _=="object"&&_!==null){switch(_.$$typeof){case w:return _.key===ne?j(T,E,_,H):null;case N:return _.key===ne?C(T,E,_,H):null;case ee:return _=za(_),M(T,E,_,H)}if(ce(_)||ye(_))return ne!==null?null:R(T,E,_,H,null);if(typeof _.then=="function")return M(T,E,pi(_),H);if(_.$$typeof===Z)return M(T,E,fi(T,_),H);xi(T,_)}return null}function O(T,E,_,H,ne){if(typeof H=="string"&&H!==""||typeof H=="number"||typeof H=="bigint")return T=T.get(_)||null,b(E,T,""+H,ne);if(typeof H=="object"&&H!==null){switch(H.$$typeof){case w:return T=T.get(H.key===null?_:H.key)||null,j(E,T,H,ne);case N:return T=T.get(H.key===null?_:H.key)||null,C(E,T,H,ne);case ee:return H=za(H),O(T,E,_,H,ne)}if(ce(H)||ye(H))return T=T.get(_)||null,R(E,T,H,ne,null);if(typeof H.then=="function")return O(T,E,_,pi(H),ne);if(H.$$typeof===Z)return O(T,E,_,fi(E,H),ne);xi(E,H)}return null}function W(T,E,_,H){for(var ne=null,Ne=null,F=E,me=E=0,be=null;F!==null&&me<_.length;me++){F.index>me?(be=F,F=null):be=F.sibling;var Se=M(T,F,_[me],H);if(Se===null){F===null&&(F=be);break}e&&F&&Se.alternate===null&&t(T,F),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se,F=be}if(me===_.length)return l(T,F),ve&&yl(T,me),ne;if(F===null){for(;me<_.length;me++)F=B(T,_[me],H),F!==null&&(E=r(F,E,me),Ne===null?ne=F:Ne.sibling=F,Ne=F);return ve&&yl(T,me),ne}for(F=a(F);me<_.length;me++)be=O(F,T,me,_[me],H),be!==null&&(e&&be.alternate!==null&&F.delete(be.key===null?me:be.key),E=r(be,E,me),Ne===null?ne=be:Ne.sibling=be,Ne=be);return e&&F.forEach(function(ia){return t(T,ia)}),ve&&yl(T,me),ne}function ie(T,E,_,H){if(_==null)throw Error(u(151));for(var ne=null,Ne=null,F=E,me=E=0,be=null,Se=_.next();F!==null&&!Se.done;me++,Se=_.next()){F.index>me?(be=F,F=null):be=F.sibling;var ia=M(T,F,Se.value,H);if(ia===null){F===null&&(F=be);break}e&&F&&ia.alternate===null&&t(T,F),E=r(ia,E,me),Ne===null?ne=ia:Ne.sibling=ia,Ne=ia,F=be}if(Se.done)return l(T,F),ve&&yl(T,me),ne;if(F===null){for(;!Se.done;me++,Se=_.next())Se=B(T,Se.value,H),Se!==null&&(E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return ve&&yl(T,me),ne}for(F=a(F);!Se.done;me++,Se=_.next())Se=O(F,T,me,Se.value,H),Se!==null&&(e&&Se.alternate!==null&&F.delete(Se.key===null?me:Se.key),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return e&&F.forEach(function(Ig){return t(T,Ig)}),ve&&yl(T,me),ne}function Re(T,E,_,H){if(typeof _=="object"&&_!==null&&_.type===A&&_.key===null&&(_=_.props.children),typeof _=="object"&&_!==null){switch(_.$$typeof){case w:e:{for(var ne=_.key;E!==null;){if(E.key===ne){if(ne=_.type,ne===A){if(E.tag===7){l(T,E.sibling),H=s(E,_.props.children),H.return=T,T=H;break e}}else if(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type){l(T,E.sibling),H=s(E,_.props),Fn(H,_),H.return=T,T=H;break e}l(T,E);break}else t(T,E);E=E.sibling}_.type===A?(H=va(_.props.children,T.mode,H,_.key),H.return=T,T=H):(H=ri(_.type,_.key,_.props,null,T.mode,H),Fn(H,_),H.return=T,T=H)}return m(T);case N:e:{for(ne=_.key;E!==null;){if(E.key===ne)if(E.tag===4&&E.stateNode.containerInfo===_.containerInfo&&E.stateNode.implementation===_.implementation){l(T,E.sibling),H=s(E,_.children||[]),H.return=T,T=H;break e}else{l(T,E);break}else t(T,E);E=E.sibling}H=Kc(_,T.mode,H),H.return=T,T=H}return m(T);case ee:return _=za(_),Re(T,E,_,H)}if(ce(_))return W(T,E,_,H);if(ye(_)){if(ne=ye(_),typeof ne!="function")throw Error(u(150));return _=ne.call(_),ie(T,E,_,H)}if(typeof _.then=="function")return Re(T,E,pi(_),H);if(_.$$typeof===Z)return Re(T,E,fi(T,_),H);xi(T,_)}return typeof _=="string"&&_!==""||typeof _=="number"||typeof _=="bigint"?(_=""+_,E!==null&&E.tag===6?(l(T,E.sibling),H=s(E,_),H.return=T,T=H):(l(T,E),H=Zc(_,T.mode,H),H.return=T,T=H),m(T)):l(T,E)}return function(T,E,_,H){try{Wn=0;var ne=Re(T,E,_,H);return nn=null,ne}catch(F){if(F===an||F===mi)throw F;var Ne=Mt(29,F,null,T.mode);return Ne.lanes=H,Ne.return=T,Ne}finally{}}}var wa=Cf(!0),Mf=Cf(!1),$l=!1;function ir(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function cr(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ql(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Xl(e,t,l){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(we&2)!==0){var s=a.pending;return s===null?t.next=t:(t.next=s.next,s.next=t),a.pending=t,t=ci(e),hf(e,null,l),t}return ii(e,a,t,l),ci(e)}function In(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}function rr(e,t){var l=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,l===a)){var s=null,r=null;if(l=l.firstBaseUpdate,l!==null){do{var m={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};r===null?s=r=m:r=r.next=m,l=l.next}while(l!==null);r===null?s=r=t:r=r.next=t}else s=r=t;l={baseState:a.baseState,firstBaseUpdate:s,lastBaseUpdate:r,shared:a.shared,callbacks:a.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var or=!1;function Pn(){if(or){var e=ln;if(e!==null)throw e}}function es(e,t,l,a){or=!1;var s=e.updateQueue;$l=!1;var r=s.firstBaseUpdate,m=s.lastBaseUpdate,b=s.shared.pending;if(b!==null){s.shared.pending=null;var j=b,C=j.next;j.next=null,m===null?r=C:m.next=C,m=j;var R=e.alternate;R!==null&&(R=R.updateQueue,b=R.lastBaseUpdate,b!==m&&(b===null?R.firstBaseUpdate=C:b.next=C,R.lastBaseUpdate=j))}if(r!==null){var B=s.baseState;m=0,R=C=j=null,b=r;do{var M=b.lane&-536870913,O=M!==b.lane;if(O?(ge&M)===M:(a&M)===M){M!==0&&M===tn&&(or=!0),R!==null&&(R=R.next={lane:0,tag:b.tag,payload:b.payload,callback:null,next:null});e:{var W=e,ie=b;M=t;var Re=l;switch(ie.tag){case 1:if(W=ie.payload,typeof W=="function"){B=W.call(Re,B,M);break e}B=W;break e;case 3:W.flags=W.flags&-65537|128;case 0:if(W=ie.payload,M=typeof W=="function"?W.call(Re,B,M):W,M==null)break e;B=y({},B,M);break e;case 2:$l=!0}}M=b.callback,M!==null&&(e.flags|=64,O&&(e.flags|=8192),O=s.callbacks,O===null?s.callbacks=[M]:O.push(M))}else O={lane:M,tag:b.tag,payload:b.payload,callback:b.callback,next:null},R===null?(C=R=O,j=B):R=R.next=O,m|=M;if(b=b.next,b===null){if(b=s.shared.pending,b===null)break;O=b,b=O.next,O.next=null,s.lastBaseUpdate=O,s.shared.pending=null}}while(!0);R===null&&(j=B),s.baseState=j,s.firstBaseUpdate=C,s.lastBaseUpdate=R,r===null&&(s.shared.lanes=0),Wl|=m,e.lanes=m,e.memoizedState=B}}function kf(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function Of(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;e<l.length;e++)kf(l[e],t)}var sn=S(null),gi=S(0);function Df(e,t){e=Ml,V(gi,e),V(sn,t),Ml=e|t.baseLanes}function ur(){V(gi,Ml),V(sn,sn.current)}function fr(){Ml=gi.current,L(sn),L(gi)}var kt=S(null),Xt=null;function Vl(e){var t=e.alternate;V(Ke,Ke.current&1),V(kt,e),Xt===null&&(t===null||sn.current!==null||t.memoizedState!==null)&&(Xt=e)}function dr(e){V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)}function Rf(e){e.tag===22?(V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)):Zl()}function Zl(){V(Ke,Ke.current),V(kt,kt.current)}function Ot(e){L(kt),Xt===e&&(Xt=null),L(Ke)}var Ke=S(0);function bi(e){for(var t=e;t!==null;){if(t.tag===13){var l=t.memoizedState;if(l!==null&&(l=l.dehydrated,l===null||vo(l)||yo(l)))return t}else if(t.tag===19&&(t.memoizedProps.revealOrder==="forwards"||t.memoizedProps.revealOrder==="backwards"||t.memoizedProps.revealOrder==="unstable_legacy-backwards"||t.memoizedProps.revealOrder==="together")){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Sl=0,de=null,Oe=null,Ie=null,vi=!1,cn=!1,Ta=!1,yi=0,ts=0,rn=null,Gx=0;function Xe(){throw Error(u(321))}function mr(e,t){if(t===null)return!1;for(var l=0;l<t.length&&l<e.length;l++)if(!Ct(e[l],t[l]))return!1;return!0}function hr(e,t,l,a,s,r){return Sl=r,de=t,t.memoizedState=null,t.updateQueue=null,t.lanes=0,k.H=e===null||e.memoizedState===null?bd:_r,Ta=!1,r=l(a,s),Ta=!1,cn&&(r=Hf(t,l,a,s)),Uf(e),r}function Uf(e){k.H=ns;var t=Oe!==null&&Oe.next!==null;if(Sl=0,Ie=Oe=de=null,vi=!1,ts=0,rn=null,t)throw Error(u(300));e===null||Pe||(e=e.dependencies,e!==null&&ui(e)&&(Pe=!0))}function Hf(e,t,l,a){de=e;var s=0;do{if(cn&&(rn=null),ts=0,cn=!1,25<=s)throw Error(u(301));if(s+=1,Ie=Oe=null,e.updateQueue!=null){var r=e.updateQueue;r.lastEffect=null,r.events=null,r.stores=null,r.memoCache!=null&&(r.memoCache.index=0)}k.H=vd,r=t(l,a)}while(cn);return r}function $x(){var e=k.H,t=e.useState()[0];return t=typeof t.then=="function"?ls(t):t,e=e.useState()[0],(Oe!==null?Oe.memoizedState:null)!==e&&(de.flags|=1024),t}function pr(){var e=yi!==0;return yi=0,e}function xr(e,t,l){t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l}function gr(e){if(vi){for(e=e.memoizedState;e!==null;){var t=e.queue;t!==null&&(t.pending=null),e=e.next}vi=!1}Sl=0,Ie=Oe=de=null,cn=!1,ts=yi=0,rn=null}function gt(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e,Ie}function Je(){if(Oe===null){var e=de.alternate;e=e!==null?e.memoizedState:null}else e=Oe.next;var t=Ie===null?de.memoizedState:Ie.next;if(t!==null)Ie=t,Oe=e;else{if(e===null)throw de.alternate===null?Error(u(467)):Error(u(310));Oe=e,e={memoizedState:Oe.memoizedState,baseState:Oe.baseState,baseQueue:Oe.baseQueue,queue:Oe.queue,next:null},Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e}return Ie}function ji(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function ls(e){var t=ts;return ts+=1,rn===null&&(rn=[]),e=Tf(rn,e,t),t=de,(Ie===null?t.memoizedState:Ie.next)===null&&(t=t.alternate,k.H=t===null||t.memoizedState===null?bd:_r),e}function Ni(e){if(e!==null&&typeof e=="object"){if(typeof e.then=="function")return ls(e);if(e.$$typeof===Z)return rt(e)}throw Error(u(438,String(e)))}function br(e){var t=null,l=de.updateQueue;if(l!==null&&(t=l.memoCache),t==null){var a=de.alternate;a!==null&&(a=a.updateQueue,a!==null&&(a=a.memoCache,a!=null&&(t={data:a.data.map(function(s){return s.slice()}),index:0})))}if(t==null&&(t={data:[],index:0}),l===null&&(l=ji(),de.updateQueue=l),l.memoCache=t,l=t.data[t.index],l===void 0)for(l=t.data[t.index]=Array(e),a=0;a<e;a++)l[a]=Q;return t.index++,l}function zl(e,t){return typeof t=="function"?t(e):t}function Si(e){var t=Je();return vr(t,Oe,e)}function vr(e,t,l){var a=e.queue;if(a===null)throw Error(u(311));a.lastRenderedReducer=l;var s=e.baseQueue,r=a.pending;if(r!==null){if(s!==null){var m=s.next;s.next=r.next,r.next=m}t.baseQueue=s=r,a.pending=null}if(r=e.baseState,s===null)e.memoizedState=r;else{t=s.next;var b=m=null,j=null,C=t,R=!1;do{var B=C.lane&-536870913;if(B!==C.lane?(ge&B)===B:(Sl&B)===B){var M=C.revertLane;if(M===0)j!==null&&(j=j.next={lane:0,revertLane:0,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null}),B===tn&&(R=!0);else if((Sl&M)===M){C=C.next,M===tn&&(R=!0);continue}else B={lane:0,revertLane:C.revertLane,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=B,m=r):j=j.next=B,de.lanes|=M,Wl|=M;B=C.action,Ta&&l(r,B),r=C.hasEagerState?C.eagerState:l(r,B)}else M={lane:B,revertLane:C.revertLane,gesture:C.gesture,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=M,m=r):j=j.next=M,de.lanes|=B,Wl|=B;C=C.next}while(C!==null&&C!==t);if(j===null?m=r:j.next=b,!Ct(r,e.memoizedState)&&(Pe=!0,R&&(l=ln,l!==null)))throw l;e.memoizedState=r,e.baseState=m,e.baseQueue=j,a.lastRenderedState=r}return s===null&&(a.lanes=0),[e.memoizedState,a.dispatch]}function yr(e){var t=Je(),l=t.queue;if(l===null)throw Error(u(311));l.lastRenderedReducer=e;var a=l.dispatch,s=l.pending,r=t.memoizedState;if(s!==null){l.pending=null;var m=s=s.next;do r=e(r,m.action),m=m.next;while(m!==s);Ct(r,t.memoizedState)||(Pe=!0),t.memoizedState=r,t.baseQueue===null&&(t.baseState=r),l.lastRenderedState=r}return[r,a]}function Lf(e,t,l){var a=de,s=Je(),r=ve;if(r){if(l===void 0)throw Error(u(407));l=l()}else l=t();var m=!Ct((Oe||s).memoizedState,l);if(m&&(s.memoizedState=l,Pe=!0),s=s.queue,Sr(Yf.bind(null,a,s,e),[e]),s.getSnapshot!==t||m||Ie!==null&&Ie.memoizedState.tag&1){if(a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,s,l,t),null),Ue===null)throw Error(u(349));r||(Sl&127)!==0||Bf(a,t,l)}return l}function Bf(e,t,l){e.flags|=16384,e={getSnapshot:t,value:l},t=de.updateQueue,t===null?(t=ji(),de.updateQueue=t,t.stores=[e]):(l=t.stores,l===null?t.stores=[e]:l.push(e))}function qf(e,t,l,a){t.value=l,t.getSnapshot=a,Gf(t)&&$f(e)}function Yf(e,t,l){return l(function(){Gf(t)&&$f(e)})}function Gf(e){var t=e.getSnapshot;e=e.value;try{var l=t();return!Ct(e,l)}catch{return!0}}function $f(e){var t=ba(e,2);t!==null&&Tt(t,e,2)}function jr(e){var t=gt();if(typeof e=="function"){var l=e;if(e=l(),Ta){nl(!0);try{l()}finally{nl(!1)}}}return t.memoizedState=t.baseState=e,t.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:e},t}function Qf(e,t,l,a){return e.baseState=l,vr(e,Oe,typeof a=="function"?a:zl)}function Qx(e,t,l,a,s){if(wi(e))throw Error(u(485));if(e=t.action,e!==null){var r={payload:s,action:e,next:null,isTransition:!0,status:"pending",value:null,reason:null,listeners:[],then:function(m){r.listeners.push(m)}};k.T!==null?l(!0):r.isTransition=!1,a(r),l=t.pending,l===null?(r.next=t.pending=r,Xf(t,r)):(r.next=l.next,t.pending=l.next=r)}}function Xf(e,t){var l=t.action,a=t.payload,s=e.state;if(t.isTransition){var r=k.T,m={};k.T=m;try{var b=l(s,a),j=k.S;j!==null&&j(m,b),Vf(e,t,b)}catch(C){Nr(e,t,C)}finally{r!==null&&m.types!==null&&(r.types=m.types),k.T=r}}else try{r=l(s,a),Vf(e,t,r)}catch(C){Nr(e,t,C)}}function Vf(e,t,l){l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(function(a){Zf(e,t,a)},function(a){return Nr(e,t,a)}):Zf(e,t,l)}function Zf(e,t,l){t.status="fulfilled",t.value=l,Kf(t),e.state=l,t=e.pending,t!==null&&(l=t.next,l===t?e.pending=null:(l=l.next,t.next=l,Xf(e,l)))}function Nr(e,t,l){var a=e.pending;if(e.pending=null,a!==null){a=a.next;do t.status="rejected",t.reason=l,Kf(t),t=t.next;while(t!==a)}e.action=null}function Kf(e){e=e.listeners;for(var t=0;t<e.length;t++)(0,e[t])()}function Jf(e,t){return t}function Wf(e,t){if(ve){var l=Ue.formState;if(l!==null){e:{var a=de;if(ve){if(Le){t:{for(var s=Le,r=Qt;s.nodeType!==8;){if(!r){s=null;break t}if(s=Vt(s.nextSibling),s===null){s=null;break t}}r=s.data,s=r==="F!"||r==="F"?s:null}if(s){Le=Vt(s.nextSibling),a=s.data==="F!";break e}}Yl(a)}a=!1}a&&(t=l[0])}}return l=gt(),l.memoizedState=l.baseState=t,a={pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jf,lastRenderedState:t},l.queue=a,l=pd.bind(null,de,a),a.dispatch=l,a=jr(!1),r=Ar.bind(null,de,!1,a.queue),a=gt(),s={state:t,dispatch:null,action:e,pending:null},a.queue=s,l=Qx.bind(null,de,s,r,l),s.dispatch=l,a.memoizedState=e,[t,l,!1]}function Ff(e){var t=Je();return If(t,Oe,e)}function If(e,t,l){if(t=vr(e,t,Jf)[0],e=Si(zl)[0],typeof t=="object"&&t!==null&&typeof t.then=="function")try{var a=ls(t)}catch(m){throw m===an?mi:m}else a=t;t=Je();var s=t.queue,r=s.dispatch;return l!==t.memoizedState&&(de.flags|=2048,on(9,{destroy:void 0},Xx.bind(null,s,l),null)),[a,r,e]}function Xx(e,t){e.action=t}function Pf(e){var t=Je(),l=Oe;if(l!==null)return If(t,l,e);Je(),t=t.memoizedState,l=Je();var a=l.queue.dispatch;return l.memoizedState=e,[t,a,!1]}function on(e,t,l,a){return e={tag:e,create:l,deps:a,inst:t,next:null},t=de.updateQueue,t===null&&(t=ji(),de.updateQueue=t),l=t.lastEffect,l===null?t.lastEffect=e.next=e:(a=l.next,l.next=e,e.next=a,t.lastEffect=e),e}function ed(){return Je().memoizedState}function zi(e,t,l,a){var s=gt();de.flags|=e,s.memoizedState=on(1|t,{destroy:void 0},l,a===void 0?null:a)}function Ei(e,t,l,a){var s=Je();a=a===void 0?null:a;var r=s.memoizedState.inst;Oe!==null&&a!==null&&mr(a,Oe.memoizedState.deps)?s.memoizedState=on(t,r,l,a):(de.flags|=e,s.memoizedState=on(1|t,r,l,a))}function td(e,t){zi(8390656,8,e,t)}function Sr(e,t){Ei(2048,8,e,t)}function Vx(e){de.flags|=4;var t=de.updateQueue;if(t===null)t=ji(),de.updateQueue=t,t.events=[e];else{var l=t.events;l===null?t.events=[e]:l.push(e)}}function ld(e){var t=Je().memoizedState;return Vx({ref:t,nextImpl:e}),function(){if((we&2)!==0)throw Error(u(440));return t.impl.apply(void 0,arguments)}}function ad(e,t){return Ei(4,2,e,t)}function nd(e,t){return Ei(4,4,e,t)}function sd(e,t){if(typeof t=="function"){e=e();var l=t(e);return function(){typeof l=="function"?l():t(null)}}if(t!=null)return e=e(),t.current=e,function(){t.current=null}}function id(e,t,l){l=l!=null?l.concat([e]):null,Ei(4,4,sd.bind(null,t,e),l)}function zr(){}function cd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;return t!==null&&mr(t,a[1])?a[0]:(l.memoizedState=[e,t],e)}function rd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;if(t!==null&&mr(t,a[1]))return a[0];if(a=e(),Ta){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a}function Er(e,t,l){return l===void 0||(Sl&1073741824)!==0&&(ge&261930)===0?e.memoizedState=t:(e.memoizedState=l,e=om(),de.lanes|=e,Wl|=e,l)}function od(e,t,l,a){return Ct(l,t)?l:sn.current!==null?(e=Er(e,l,a),Ct(e,t)||(Pe=!0),e):(Sl&42)===0||(Sl&1073741824)!==0&&(ge&261930)===0?(Pe=!0,e.memoizedState=l):(e=om(),de.lanes|=e,Wl|=e,t)}function ud(e,t,l,a,s){var r=Y.p;Y.p=r!==0&&8>r?r:8;var m=k.T,b={};k.T=b,Ar(e,!1,t,l);try{var j=s(),C=k.S;if(C!==null&&C(b,j),j!==null&&typeof j=="object"&&typeof j.then=="function"){var R=Yx(j,a);as(e,t,R,Ut(e))}else as(e,t,a,Ut(e))}catch(B){as(e,t,{then:function(){},status:"rejected",reason:B},Ut())}finally{Y.p=r,m!==null&&b.types!==null&&(m.types=b.types),k.T=m}}function Zx(){}function wr(e,t,l,a){if(e.tag!==5)throw Error(u(476));var s=fd(e).queue;ud(e,s,t,te,l===null?Zx:function(){return dd(e),l(a)})}function fd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:te,baseState:te,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:te},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function dd(e){var t=fd(e);t.next===null&&(t=e.alternate.memoizedState),as(e,t.next.queue,{},Ut())}function Tr(){return rt(ys)}function md(){return Je().memoizedState}function hd(){return Je().memoizedState}function Kx(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Ut();e=Ql(l);var a=Xl(t,e,l);a!==null&&(Tt(a,t,l),In(a,t,l)),t={cache:lr()},e.payload=t;return}t=t.return}}function Jx(e,t,l){var a=Ut();l={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},wi(e)?xd(t,l):(l=Xc(e,t,l,a),l!==null&&(Tt(l,e,a),gd(l,t,a)))}function pd(e,t,l){var a=Ut();as(e,t,l,a)}function as(e,t,l,a){var s={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(wi(e))xd(t,s);else{var r=e.alternate;if(e.lanes===0&&(r===null||r.lanes===0)&&(r=t.lastRenderedReducer,r!==null))try{var m=t.lastRenderedState,b=r(m,l);if(s.hasEagerState=!0,s.eagerState=b,Ct(b,m))return ii(e,t,s,0),Ue===null&&si(),!1}catch{}finally{}if(l=Xc(e,t,s,a),l!==null)return Tt(l,e,a),gd(l,t,a),!0}return!1}function Ar(e,t,l,a){if(a={lane:2,revertLane:io(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},wi(e)){if(t)throw Error(u(479))}else t=Xc(e,l,a,2),t!==null&&Tt(t,e,2)}function wi(e){var t=e.alternate;return e===de||t!==null&&t===de}function xd(e,t){cn=vi=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function gd(e,t,l){if((l&4194048)!==0){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}var ns={readContext:rt,use:Ni,useCallback:Xe,useContext:Xe,useEffect:Xe,useImperativeHandle:Xe,useLayoutEffect:Xe,useInsertionEffect:Xe,useMemo:Xe,useReducer:Xe,useRef:Xe,useState:Xe,useDebugValue:Xe,useDeferredValue:Xe,useTransition:Xe,useSyncExternalStore:Xe,useId:Xe,useHostTransitionStatus:Xe,useFormState:Xe,useActionState:Xe,useOptimistic:Xe,useMemoCache:Xe,useCacheRefresh:Xe};ns.useEffectEvent=Xe;var bd={readContext:rt,use:Ni,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:rt,useEffect:td,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,zi(4194308,4,sd.bind(null,t,e),l)},useLayoutEffect:function(e,t){return zi(4194308,4,e,t)},useInsertionEffect:function(e,t){zi(4,2,e,t)},useMemo:function(e,t){var l=gt();t=t===void 0?null:t;var a=e();if(Ta){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a},useReducer:function(e,t,l){var a=gt();if(l!==void 0){var s=l(t);if(Ta){nl(!0);try{l(t)}finally{nl(!1)}}}else s=t;return a.memoizedState=a.baseState=s,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:s},a.queue=e,e=e.dispatch=Jx.bind(null,de,e),[a.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:function(e){e=jr(e);var t=e.queue,l=pd.bind(null,de,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:zr,useDeferredValue:function(e,t){var l=gt();return Er(l,e,t)},useTransition:function(){var e=jr(!1);return e=ud.bind(null,de,e.queue,!0,!1),gt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var a=de,s=gt();if(ve){if(l===void 0)throw Error(u(407));l=l()}else{if(l=t(),Ue===null)throw Error(u(349));(ge&127)!==0||Bf(a,t,l)}s.memoizedState=l;var r={value:l,getSnapshot:t};return s.queue=r,td(Yf.bind(null,a,r,e),[e]),a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,r,l,t),null),l},useId:function(){var e=gt(),t=Ue.identifierPrefix;if(ve){var l=cl,a=il;l=(a&~(1<<32-xt(a)-1)).toString(32)+l,t="_"+t+"R_"+l,l=yi++,0<l&&(t+="H"+l.toString(32)),t+="_"}else l=Gx++,t="_"+t+"r_"+l.toString(32)+"_";return e.memoizedState=t},useHostTransitionStatus:Tr,useFormState:Wf,useActionState:Wf,useOptimistic:function(e){var t=gt();t.memoizedState=t.baseState=e;var l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return t.queue=l,t=Ar.bind(null,de,!0,l),l.dispatch=t,[e,t]},useMemoCache:br,useCacheRefresh:function(){return gt().memoizedState=Kx.bind(null,de)},useEffectEvent:function(e){var t=gt(),l={impl:e};return t.memoizedState=l,function(){if((we&2)!==0)throw Error(u(440));return l.impl.apply(void 0,arguments)}}},_r={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:Si,useRef:ed,useState:function(){return Si(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=Si(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:Tr,useFormState:Ff,useActionState:Ff,useOptimistic:function(e,t){var l=Je();return Qf(l,Oe,e,t)},useMemoCache:br,useCacheRefresh:hd};_r.useEffectEvent=ld;var vd={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:yr,useRef:ed,useState:function(){return yr(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return Oe===null?Er(l,e,t):od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=yr(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:Tr,useFormState:Pf,useActionState:Pf,useOptimistic:function(e,t){var l=Je();return Oe!==null?Qf(l,Oe,e,t):(l.baseState=e,[e,l.queue.dispatch])},useMemoCache:br,useCacheRefresh:hd};vd.useEffectEvent=ld;function Cr(e,t,l,a){t=e.memoizedState,l=l(a,t),l=l==null?t:y({},t,l),e.memoizedState=l,e.lanes===0&&(e.updateQueue.baseState=l)}var Mr={enqueueSetState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(Tt(t,e,a),In(t,e,a))},enqueueReplaceState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.tag=1,s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(Tt(t,e,a),In(t,e,a))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var l=Ut(),a=Ql(l);a.tag=2,t!=null&&(a.callback=t),t=Xl(e,a,l),t!==null&&(Tt(t,e,l),In(t,e,l))}};function yd(e,t,l,a,s,r,m){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(a,r,m):t.prototype&&t.prototype.isPureReactComponent?!Qn(l,a)||!Qn(s,r):!0}function jd(e,t,l,a){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(l,a),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(l,a),t.state!==e&&Mr.enqueueReplaceState(t,t.state,null)}function Aa(e,t){var l=t;if("ref"in t){l={};for(var a in t)a!=="ref"&&(l[a]=t[a])}if(e=e.defaultProps){l===t&&(l=y({},l));for(var s in e)l[s]===void 0&&(l[s]=e[s])}return l}function Nd(e){ni(e)}function Sd(e){console.error(e)}function zd(e){ni(e)}function Ti(e,t){try{var l=e.onUncaughtError;l(t.value,{componentStack:t.stack})}catch(a){setTimeout(function(){throw a})}}function Ed(e,t,l){try{var a=e.onCaughtError;a(l.value,{componentStack:l.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(s){setTimeout(function(){throw s})}}function kr(e,t,l){return l=Ql(l),l.tag=3,l.payload={element:null},l.callback=function(){Ti(e,t)},l}function wd(e){return e=Ql(e),e.tag=3,e}function Td(e,t,l,a){var s=l.type.getDerivedStateFromError;if(typeof s=="function"){var r=a.value;e.payload=function(){return s(r)},e.callback=function(){Ed(t,l,a)}}var m=l.stateNode;m!==null&&typeof m.componentDidCatch=="function"&&(e.callback=function(){Ed(t,l,a),typeof s!="function"&&(Fl===null?Fl=new Set([this]):Fl.add(this));var b=a.stack;this.componentDidCatch(a.value,{componentStack:b!==null?b:""})})}function Wx(e,t,l,a,s){if(l.flags|=32768,a!==null&&typeof a=="object"&&typeof a.then=="function"){if(t=l.alternate,t!==null&&en(t,l,s,!0),l=kt.current,l!==null){switch(l.tag){case 31:case 13:return Xt===null?Bi():l.alternate===null&&Ve===0&&(Ve=3),l.flags&=-257,l.flags|=65536,l.lanes=s,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?l.updateQueue=new Set([a]):t.add(a),ao(e,a,s)),!1;case 22:return l.flags|=65536,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([a])},l.updateQueue=t):(l=t.retryQueue,l===null?t.retryQueue=new Set([a]):l.add(a)),ao(e,a,s)),!1}throw Error(u(435,l.tag))}return ao(e,a,s),Bi(),!1}if(ve)return t=kt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=s,a!==Fc&&(e=Error(u(422),{cause:a}),Zn(Yt(e,l)))):(a!==Fc&&(t=Error(u(423),{cause:a}),Zn(Yt(t,l))),e=e.current.alternate,e.flags|=65536,s&=-s,e.lanes|=s,a=Yt(a,l),s=kr(e.stateNode,a,s),rr(e,s),Ve!==4&&(Ve=2)),!1;var r=Error(u(520),{cause:a});if(r=Yt(r,l),ds===null?ds=[r]:ds.push(r),Ve!==4&&(Ve=2),t===null)return!0;a=Yt(a,l),l=t;do{switch(l.tag){case 3:return l.flags|=65536,e=s&-s,l.lanes|=e,e=kr(l.stateNode,a,e),rr(l,e),!1;case 1:if(t=l.type,r=l.stateNode,(l.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||r!==null&&typeof r.componentDidCatch=="function"&&(Fl===null||!Fl.has(r))))return l.flags|=65536,s&=-s,l.lanes|=s,s=wd(s),Td(s,e,l,a),rr(l,s),!1}l=l.return}while(l!==null);return!1}var Or=Error(u(461)),Pe=!1;function ot(e,t,l,a){t.child=e===null?Mf(t,null,l,a):wa(t,e.child,l,a)}function Ad(e,t,l,a,s){l=l.render;var r=t.ref;if("ref"in a){var m={};for(var b in a)b!=="ref"&&(m[b]=a[b])}else m=a;return Na(t),a=hr(e,t,l,m,r,s),b=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&b&&Jc(t),t.flags|=1,ot(e,t,a,s),t.child)}function _d(e,t,l,a,s){if(e===null){var r=l.type;return typeof r=="function"&&!Vc(r)&&r.defaultProps===void 0&&l.compare===null?(t.tag=15,t.type=r,Cd(e,t,r,a,s)):(e=ri(l.type,null,a,t,t.mode,s),e.ref=t.ref,e.return=t,t.child=e)}if(r=e.child,!Yr(e,s)){var m=r.memoizedProps;if(l=l.compare,l=l!==null?l:Qn,l(m,a)&&e.ref===t.ref)return El(e,t,s)}return t.flags|=1,e=vl(r,a),e.ref=t.ref,e.return=t,t.child=e}function Cd(e,t,l,a,s){if(e!==null){var r=e.memoizedProps;if(Qn(r,a)&&e.ref===t.ref)if(Pe=!1,t.pendingProps=a=r,Yr(e,s))(e.flags&131072)!==0&&(Pe=!0);else return t.lanes=e.lanes,El(e,t,s)}return Dr(e,t,l,a,s)}function Md(e,t,l,a){var s=a.children,r=e!==null?e.memoizedState:null;if(e===null&&t.stateNode===null&&(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),a.mode==="hidden"){if((t.flags&128)!==0){if(r=r!==null?r.baseLanes|l:l,e!==null){for(a=t.child=e.child,s=0;a!==null;)s=s|a.lanes|a.childLanes,a=a.sibling;a=s&~r}else a=0,t.child=null;return kd(e,t,r,l,a)}if((l&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&di(t,r!==null?r.cachePool:null),r!==null?Df(t,r):ur(),Rf(t);else return a=t.lanes=536870912,kd(e,t,r!==null?r.baseLanes|l:l,l,a)}else r!==null?(di(t,r.cachePool),Df(t,r),Zl(),t.memoizedState=null):(e!==null&&di(t,null),ur(),Zl());return ot(e,t,s,l),t.child}function ss(e,t){return e!==null&&e.tag===22||t.stateNode!==null||(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),t.sibling}function kd(e,t,l,a,s){var r=nr();return r=r===null?null:{parent:Fe._currentValue,pool:r},t.memoizedState={baseLanes:l,cachePool:r},e!==null&&di(t,null),ur(),Rf(t),e!==null&&en(e,t,a,!0),t.childLanes=s,null}function Ai(e,t){return t=Ci({mode:t.mode,children:t.children},e.mode),t.ref=e.ref,e.child=t,t.return=e,t}function Od(e,t,l){return wa(t,e.child,null,l),e=Ai(t,t.pendingProps),e.flags|=2,Ot(t),t.memoizedState=null,e}function Fx(e,t,l){var a=t.pendingProps,s=(t.flags&128)!==0;if(t.flags&=-129,e===null){if(ve){if(a.mode==="hidden")return e=Ai(t,a),t.lanes=536870912,ss(null,e);if(dr(t),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data==="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return t.lanes=536870912,null}return Ai(t,a)}var r=e.memoizedState;if(r!==null){var m=r.dehydrated;if(dr(t),s)if(t.flags&256)t.flags&=-257,t=Od(e,t,l);else if(t.memoizedState!==null)t.child=e.child,t.flags|=128,t=null;else throw Error(u(558));else if(Pe||en(e,t,l,!1),s=(l&e.childLanes)!==0,Pe||s){if(a=Ue,a!==null&&(m=Nu(a,l),m!==0&&m!==r.retryLane))throw r.retryLane=m,ba(e,m),Tt(a,e,m),Or;Bi(),t=Od(e,t,l)}else e=r.treeContext,Le=Vt(m.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Ai(t,a),t.flags|=4096;return t}return e=vl(e.child,{mode:a.mode,children:a.children}),e.ref=t.ref,t.child=e,e.return=t,e}function _i(e,t){var l=t.ref;if(l===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof l!="function"&&typeof l!="object")throw Error(u(284));(e===null||e.ref!==l)&&(t.flags|=4194816)}}function Dr(e,t,l,a,s){return Na(t),l=hr(e,t,l,a,void 0,s),a=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,s),t.child)}function Dd(e,t,l,a,s,r){return Na(t),t.updateQueue=null,l=Hf(t,a,l,s),Uf(e),a=pr(),e!==null&&!Pe?(xr(e,t,r),El(e,t,r)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,r),t.child)}function Rd(e,t,l,a,s){if(Na(t),t.stateNode===null){var r=Wa,m=l.contextType;typeof m=="object"&&m!==null&&(r=rt(m)),r=new l(a,r),t.memoizedState=r.state!==null&&r.state!==void 0?r.state:null,r.updater=Mr,t.stateNode=r,r._reactInternals=t,r=t.stateNode,r.props=a,r.state=t.memoizedState,r.refs={},ir(t),m=l.contextType,r.context=typeof m=="object"&&m!==null?rt(m):Wa,r.state=t.memoizedState,m=l.getDerivedStateFromProps,typeof m=="function"&&(Cr(t,l,m,a),r.state=t.memoizedState),typeof l.getDerivedStateFromProps=="function"||typeof r.getSnapshotBeforeUpdate=="function"||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(m=r.state,typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount(),m!==r.state&&Mr.enqueueReplaceState(r,r.state,null),es(t,a,r,s),Pn(),r.state=t.memoizedState),typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!0}else if(e===null){r=t.stateNode;var b=t.memoizedProps,j=Aa(l,b);r.props=j;var C=r.context,R=l.contextType;m=Wa,typeof R=="object"&&R!==null&&(m=rt(R));var B=l.getDerivedStateFromProps;R=typeof B=="function"||typeof r.getSnapshotBeforeUpdate=="function",b=t.pendingProps!==b,R||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(b||C!==m)&&jd(t,r,a,m),$l=!1;var M=t.memoizedState;r.state=M,es(t,a,r,s),Pn(),C=t.memoizedState,b||M!==C||$l?(typeof B=="function"&&(Cr(t,l,B,a),C=t.memoizedState),(j=$l||yd(t,l,j,a,M,C,m))?(R||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount()),typeof r.componentDidMount=="function"&&(t.flags|=4194308)):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=a,t.memoizedState=C),r.props=a,r.state=C,r.context=m,a=j):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!1)}else{r=t.stateNode,cr(e,t),m=t.memoizedProps,R=Aa(l,m),r.props=R,B=t.pendingProps,M=r.context,C=l.contextType,j=Wa,typeof C=="object"&&C!==null&&(j=rt(C)),b=l.getDerivedStateFromProps,(C=typeof b=="function"||typeof r.getSnapshotBeforeUpdate=="function")||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(m!==B||M!==j)&&jd(t,r,a,j),$l=!1,M=t.memoizedState,r.state=M,es(t,a,r,s),Pn();var O=t.memoizedState;m!==B||M!==O||$l||e!==null&&e.dependencies!==null&&ui(e.dependencies)?(typeof b=="function"&&(Cr(t,l,b,a),O=t.memoizedState),(R=$l||yd(t,l,R,a,M,O,j)||e!==null&&e.dependencies!==null&&ui(e.dependencies))?(C||typeof r.UNSAFE_componentWillUpdate!="function"&&typeof r.componentWillUpdate!="function"||(typeof r.componentWillUpdate=="function"&&r.componentWillUpdate(a,O,j),typeof r.UNSAFE_componentWillUpdate=="function"&&r.UNSAFE_componentWillUpdate(a,O,j)),typeof r.componentDidUpdate=="function"&&(t.flags|=4),typeof r.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),t.memoizedProps=a,t.memoizedState=O),r.props=a,r.state=O,r.context=j,a=R):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),a=!1)}return r=a,_i(e,t),a=(t.flags&128)!==0,r||a?(r=t.stateNode,l=a&&typeof l.getDerivedStateFromError!="function"?null:r.render(),t.flags|=1,e!==null&&a?(t.child=wa(t,e.child,null,s),t.child=wa(t,null,l,s)):ot(e,t,l,s),t.memoizedState=r.state,e=t.child):e=El(e,t,s),e}function Ud(e,t,l,a){return ya(),t.flags|=256,ot(e,t,l,a),t.child}var Rr={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Ur(e){return{baseLanes:e,cachePool:Ef()}}function Hr(e,t,l){return e=e!==null?e.childLanes&~l:0,t&&(e|=Rt),e}function Hd(e,t,l){var a=t.pendingProps,s=!1,r=(t.flags&128)!==0,m;if((m=r)||(m=e!==null&&e.memoizedState===null?!1:(Ke.current&2)!==0),m&&(s=!0,t.flags&=-129),m=(t.flags&32)!==0,t.flags&=-33,e===null){if(ve){if(s?Vl(t):Zl(),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data!=="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return yo(e)?t.lanes=32:t.lanes=536870912,null}var b=a.children;return a=a.fallback,s?(Zl(),s=t.mode,b=Ci({mode:"hidden",children:b},s),a=va(a,s,l,null),b.return=t,a.return=t,b.sibling=a,t.child=b,a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(null,a)):(Vl(t),Lr(t,b))}var j=e.memoizedState;if(j!==null&&(b=j.dehydrated,b!==null)){if(r)t.flags&256?(Vl(t),t.flags&=-257,t=Br(e,t,l)):t.memoizedState!==null?(Zl(),t.child=e.child,t.flags|=128,t=null):(Zl(),b=a.fallback,s=t.mode,a=Ci({mode:"visible",children:a.children},s),b=va(b,s,l,null),b.flags|=2,a.return=t,b.return=t,a.sibling=b,t.child=a,wa(t,e.child,null,l),a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,t=ss(null,a));else if(Vl(t),yo(b)){if(m=b.nextSibling&&b.nextSibling.dataset,m)var C=m.dgst;m=C,a=Error(u(419)),a.stack="",a.digest=m,Zn({value:a,source:null,stack:null}),t=Br(e,t,l)}else if(Pe||en(e,t,l,!1),m=(l&e.childLanes)!==0,Pe||m){if(m=Ue,m!==null&&(a=Nu(m,l),a!==0&&a!==j.retryLane))throw j.retryLane=a,ba(e,a),Tt(m,e,a),Or;vo(b)||Bi(),t=Br(e,t,l)}else vo(b)?(t.flags|=192,t.child=e.child,t=null):(e=j.treeContext,Le=Vt(b.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Lr(t,a.children),t.flags|=4096);return t}return s?(Zl(),b=a.fallback,s=t.mode,j=e.child,C=j.sibling,a=vl(j,{mode:"hidden",children:a.children}),a.subtreeFlags=j.subtreeFlags&65011712,C!==null?b=vl(C,b):(b=va(b,s,l,null),b.flags|=2),b.return=t,a.return=t,a.sibling=b,t.child=a,ss(null,a),a=t.child,b=e.child.memoizedState,b===null?b=Ur(l):(s=b.cachePool,s!==null?(j=Fe._currentValue,s=s.parent!==j?{parent:j,pool:j}:s):s=Ef(),b={baseLanes:b.baseLanes|l,cachePool:s}),a.memoizedState=b,a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(e.child,a)):(Vl(t),l=e.child,e=l.sibling,l=vl(l,{mode:"visible",children:a.children}),l.return=t,l.sibling=null,e!==null&&(m=t.deletions,m===null?(t.deletions=[e],t.flags|=16):m.push(e)),t.child=l,t.memoizedState=null,l)}function Lr(e,t){return t=Ci({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Ci(e,t){return e=Mt(22,e,null,t),e.lanes=0,e}function Br(e,t,l){return wa(t,e.child,null,l),e=Lr(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Ld(e,t,l){e.lanes|=t;var a=e.alternate;a!==null&&(a.lanes|=t),er(e.return,t,l)}function qr(e,t,l,a,s,r){var m=e.memoizedState;m===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:a,tail:l,tailMode:s,treeForkCount:r}:(m.isBackwards=t,m.rendering=null,m.renderingStartTime=0,m.last=a,m.tail=l,m.tailMode=s,m.treeForkCount=r)}function Bd(e,t,l){var a=t.pendingProps,s=a.revealOrder,r=a.tail;a=a.children;var m=Ke.current,b=(m&2)!==0;if(b?(m=m&1|2,t.flags|=128):m&=1,V(Ke,m),ot(e,t,a,l),a=ve?Vn:0,!b&&e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Ld(e,l,t);else if(e.tag===19)Ld(e,l,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}switch(s){case"forwards":for(l=t.child,s=null;l!==null;)e=l.alternate,e!==null&&bi(e)===null&&(s=l),l=l.sibling;l=s,l===null?(s=t.child,t.child=null):(s=l.sibling,l.sibling=null),qr(t,!1,s,l,r,a);break;case"backwards":case"unstable_legacy-backwards":for(l=null,s=t.child,t.child=null;s!==null;){if(e=s.alternate,e!==null&&bi(e)===null){t.child=s;break}e=s.sibling,s.sibling=l,l=s,s=e}qr(t,!0,l,null,r,a);break;case"together":qr(t,!1,null,null,void 0,a);break;default:t.memoizedState=null}return t.child}function El(e,t,l){if(e!==null&&(t.dependencies=e.dependencies),Wl|=t.lanes,(l&t.childLanes)===0)if(e!==null){if(en(e,t,l,!1),(l&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(u(153));if(t.child!==null){for(e=t.child,l=vl(e,e.pendingProps),t.child=l,l.return=t;e.sibling!==null;)e=e.sibling,l=l.sibling=vl(e,e.pendingProps),l.return=t;l.sibling=null}return t.child}function Yr(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&ui(e)))}function Ix(e,t,l){switch(t.tag){case 3:ze(t,t.stateNode.containerInfo),Gl(t,Fe,e.memoizedState.cache),ya();break;case 27:case 5:at(t);break;case 4:ze(t,t.stateNode.containerInfo);break;case 10:Gl(t,t.type,t.memoizedProps.value);break;case 31:if(t.memoizedState!==null)return t.flags|=128,dr(t),null;break;case 13:var a=t.memoizedState;if(a!==null)return a.dehydrated!==null?(Vl(t),t.flags|=128,null):(l&t.child.childLanes)!==0?Hd(e,t,l):(Vl(t),e=El(e,t,l),e!==null?e.sibling:null);Vl(t);break;case 19:var s=(e.flags&128)!==0;if(a=(l&t.childLanes)!==0,a||(en(e,t,l,!1),a=(l&t.childLanes)!==0),s){if(a)return Bd(e,t,l);t.flags|=128}if(s=t.memoizedState,s!==null&&(s.rendering=null,s.tail=null,s.lastEffect=null),V(Ke,Ke.current),a)break;return null;case 22:return t.lanes=0,Md(e,t,l,t.pendingProps);case 24:Gl(t,Fe,e.memoizedState.cache)}return El(e,t,l)}function qd(e,t,l){if(e!==null)if(e.memoizedProps!==t.pendingProps)Pe=!0;else{if(!Yr(e,l)&&(t.flags&128)===0)return Pe=!1,Ix(e,t,l);Pe=(e.flags&131072)!==0}else Pe=!1,ve&&(t.flags&1048576)!==0&&bf(t,Vn,t.index);switch(t.lanes=0,t.tag){case 16:e:{var a=t.pendingProps;if(e=za(t.elementType),t.type=e,typeof e=="function")Vc(e)?(a=Aa(e,a),t.tag=1,t=Rd(null,t,e,a,l)):(t.tag=0,t=Dr(null,t,e,a,l));else{if(e!=null){var s=e.$$typeof;if(s===P){t.tag=11,t=Ad(null,t,e,a,l);break e}else if(s===G){t.tag=14,t=_d(null,t,e,a,l);break e}}throw t=Qe(e)||e,Error(u(306,t,""))}}return t;case 0:return Dr(e,t,t.type,t.pendingProps,l);case 1:return a=t.type,s=Aa(a,t.pendingProps),Rd(e,t,a,s,l);case 3:e:{if(ze(t,t.stateNode.containerInfo),e===null)throw Error(u(387));a=t.pendingProps;var r=t.memoizedState;s=r.element,cr(e,t),es(t,a,null,l);var m=t.memoizedState;if(a=m.cache,Gl(t,Fe,a),a!==r.cache&&tr(t,[Fe],l,!0),Pn(),a=m.element,r.isDehydrated)if(r={element:a,isDehydrated:!1,cache:m.cache},t.updateQueue.baseState=r,t.memoizedState=r,t.flags&256){t=Ud(e,t,a,l);break e}else if(a!==s){s=Yt(Error(u(424)),t),Zn(s),t=Ud(e,t,a,l);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Le=Vt(e.firstChild),ct=t,ve=!0,ql=null,Qt=!0,l=Mf(t,null,a,l),t.child=l;l;)l.flags=l.flags&-3|4096,l=l.sibling}else{if(ya(),a===s){t=El(e,t,l);break e}ot(e,t,a,l)}t=t.child}return t;case 26:return _i(e,t),e===null?(l=Fm(t.type,null,t.pendingProps,null))?t.memoizedState=l:ve||(l=t.type,e=t.pendingProps,a=Vi(fe.current).createElement(l),a[it]=t,a[jt]=e,ut(a,l,e),nt(a),t.stateNode=a):t.memoizedState=Fm(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return at(t),e===null&&ve&&(a=t.stateNode=Km(t.type,t.pendingProps,fe.current),ct=t,Qt=!0,s=Le,ta(t.type)?(jo=s,Le=Vt(a.firstChild)):Le=s),ot(e,t,t.pendingProps.children,l),_i(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&ve&&((s=a=Le)&&(a=Ag(a,t.type,t.pendingProps,Qt),a!==null?(t.stateNode=a,ct=t,Le=Vt(a.firstChild),Qt=!1,s=!0):s=!1),s||Yl(t)),at(t),s=t.type,r=t.pendingProps,m=e!==null?e.memoizedProps:null,a=r.children,xo(s,r)?a=null:m!==null&&xo(s,m)&&(t.flags|=32),t.memoizedState!==null&&(s=hr(e,t,$x,null,null,l),ys._currentValue=s),_i(e,t),ot(e,t,a,l),t.child;case 6:return e===null&&ve&&((e=l=Le)&&(l=_g(l,t.pendingProps,Qt),l!==null?(t.stateNode=l,ct=t,Le=null,e=!0):e=!1),e||Yl(t)),null;case 13:return Hd(e,t,l);case 4:return ze(t,t.stateNode.containerInfo),a=t.pendingProps,e===null?t.child=wa(t,null,a,l):ot(e,t,a,l),t.child;case 11:return Ad(e,t,t.type,t.pendingProps,l);case 7:return ot(e,t,t.pendingProps,l),t.child;case 8:return ot(e,t,t.pendingProps.children,l),t.child;case 12:return ot(e,t,t.pendingProps.children,l),t.child;case 10:return a=t.pendingProps,Gl(t,t.type,a.value),ot(e,t,a.children,l),t.child;case 9:return s=t.type._context,a=t.pendingProps.children,Na(t),s=rt(s),a=a(s),t.flags|=1,ot(e,t,a,l),t.child;case 14:return _d(e,t,t.type,t.pendingProps,l);case 15:return Cd(e,t,t.type,t.pendingProps,l);case 19:return Bd(e,t,l);case 31:return Fx(e,t,l);case 22:return Md(e,t,l,t.pendingProps);case 24:return Na(t),a=rt(Fe),e===null?(s=nr(),s===null&&(s=Ue,r=lr(),s.pooledCache=r,r.refCount++,r!==null&&(s.pooledCacheLanes|=l),s=r),t.memoizedState={parent:a,cache:s},ir(t),Gl(t,Fe,s)):((e.lanes&l)!==0&&(cr(e,t),es(t,null,null,l),Pn()),s=e.memoizedState,r=t.memoizedState,s.parent!==a?(s={parent:a,cache:a},t.memoizedState=s,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=s),Gl(t,Fe,a)):(a=r.cache,Gl(t,Fe,a),a!==s.cache&&tr(t,[Fe],l,!0))),ot(e,t,t.pendingProps.children,l),t.child;case 29:throw t.pendingProps}throw Error(u(156,t.tag))}function wl(e){e.flags|=4}function Gr(e,t,l,a,s){if((t=(e.mode&32)!==0)&&(t=!1),t){if(e.flags|=16777216,(s&335544128)===s)if(e.stateNode.complete)e.flags|=8192;else if(mm())e.flags|=8192;else throw Ea=hi,sr}else e.flags&=-16777217}function Yd(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!lh(t))if(mm())e.flags|=8192;else throw Ea=hi,sr}function Mi(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?Zs():536870912,e.lanes|=t,mn|=t)}function is(e,t){if(!ve)switch(e.tailMode){case"hidden":t=e.tail;for(var l=null;t!==null;)t.alternate!==null&&(l=t),t=t.sibling;l===null?e.tail=null:l.sibling=null;break;case"collapsed":l=e.tail;for(var a=null;l!==null;)l.alternate!==null&&(a=l),l=l.sibling;a===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:a.sibling=null}}function Be(e){var t=e.alternate!==null&&e.alternate.child===e.child,l=0,a=0;if(t)for(var s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags&65011712,a|=s.flags&65011712,s.return=e,s=s.sibling;else for(s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags,a|=s.flags,s.return=e,s=s.sibling;return e.subtreeFlags|=a,e.childLanes=l,t}function Px(e,t,l){var a=t.pendingProps;switch(Wc(t),t.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Be(t),null;case 1:return Be(t),null;case 3:return l=t.stateNode,a=null,e!==null&&(a=e.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Nl(Fe),Ee(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(e===null||e.child===null)&&(Pa(t)?wl(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,Ic())),Be(t),null;case 26:var s=t.type,r=t.memoizedState;return e===null?(wl(t),r!==null?(Be(t),Yd(t,r)):(Be(t),Gr(t,s,null,a,l))):r?r!==e.memoizedState?(wl(t),Be(t),Yd(t,r)):(Be(t),t.flags&=-16777217):(e=e.memoizedProps,e!==a&&wl(t),Be(t),Gr(t,s,e,a,l)),null;case 27:if(Me(t),l=fe.current,s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}e=J.current,Pa(t)?yf(t):(e=Km(s,a,l),t.stateNode=e,wl(t))}return Be(t),null;case 5:if(Me(t),s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}if(r=J.current,Pa(t))yf(t);else{var m=Vi(fe.current);switch(r){case 1:r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case 2:r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;default:switch(s){case"svg":r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case"math":r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;case"script":r=m.createElement("div"),r.innerHTML="<script><\/script>",r=r.removeChild(r.firstChild);break;case"select":r=typeof a.is=="string"?m.createElement("select",{is:a.is}):m.createElement("select"),a.multiple?r.multiple=!0:a.size&&(r.size=a.size);break;default:r=typeof a.is=="string"?m.createElement(s,{is:a.is}):m.createElement(s)}}r[it]=t,r[jt]=a;e:for(m=t.child;m!==null;){if(m.tag===5||m.tag===6)r.appendChild(m.stateNode);else if(m.tag!==4&&m.tag!==27&&m.child!==null){m.child.return=m,m=m.child;continue}if(m===t)break e;for(;m.sibling===null;){if(m.return===null||m.return===t)break e;m=m.return}m.sibling.return=m.return,m=m.sibling}t.stateNode=r;e:switch(ut(r,s,a),s){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break e;case"img":a=!0;break e;default:a=!1}a&&wl(t)}}return Be(t),Gr(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(u(166));if(e=fe.current,Pa(t)){if(e=t.stateNode,l=t.memoizedProps,a=null,s=ct,s!==null)switch(s.tag){case 27:case 5:a=s.memoizedProps}e[it]=t,e=!!(e.nodeValue===l||a!==null&&a.suppressHydrationWarning===!0||Hm(e.nodeValue,l)),e||Yl(t,!0)}else e=Vi(e).createTextNode(a),e[it]=t,t.stateNode=e}return Be(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(a=Pa(t),l!==null){if(e===null){if(!a)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),e=!1}else l=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(Ot(t),t):(Ot(t),null);if((t.flags&128)!==0)throw Error(u(558))}return Be(t),null;case 13:if(a=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(s=Pa(t),a!==null&&a.dehydrated!==null){if(e===null){if(!s)throw Error(u(318));if(s=t.memoizedState,s=s!==null?s.dehydrated:null,!s)throw Error(u(317));s[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),s=!1}else s=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=s),s=!0;if(!s)return t.flags&256?(Ot(t),t):(Ot(t),null)}return Ot(t),(t.flags&128)!==0?(t.lanes=l,t):(l=a!==null,e=e!==null&&e.memoizedState!==null,l&&(a=t.child,s=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(s=a.alternate.memoizedState.cachePool.pool),r=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(r=a.memoizedState.cachePool.pool),r!==s&&(a.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),Mi(t,t.updateQueue),Be(t),null);case 4:return Ee(),e===null&&uo(t.stateNode.containerInfo),Be(t),null;case 10:return Nl(t.type),Be(t),null;case 19:if(L(Ke),a=t.memoizedState,a===null)return Be(t),null;if(s=(t.flags&128)!==0,r=a.rendering,r===null)if(s)is(a,!1);else{if(Ve!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(r=bi(e),r!==null){for(t.flags|=128,is(a,!1),e=r.updateQueue,t.updateQueue=e,Mi(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)pf(l,e),l=l.sibling;return V(Ke,Ke.current&1|2),ve&&yl(t,a.treeForkCount),t.child}e=e.sibling}a.tail!==null&&dt()>Ui&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304)}else{if(!s)if(e=bi(r),e!==null){if(t.flags|=128,s=!0,e=e.updateQueue,t.updateQueue=e,Mi(t,e),is(a,!0),a.tail===null&&a.tailMode==="hidden"&&!r.alternate&&!ve)return Be(t),null}else 2*dt()-a.renderingStartTime>Ui&&l!==536870912&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304);a.isBackwards?(r.sibling=t.child,t.child=r):(e=a.last,e!==null?e.sibling=r:t.child=r,a.last=r)}return a.tail!==null?(e=a.tail,a.rendering=e,a.tail=e.sibling,a.renderingStartTime=dt(),e.sibling=null,l=Ke.current,V(Ke,s?l&1|2:l&1),ve&&yl(t,a.treeForkCount),e):(Be(t),null);case 22:case 23:return Ot(t),fr(),a=t.memoizedState!==null,e!==null?e.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(l&536870912)!==0&&(t.flags&128)===0&&(Be(t),t.subtreeFlags&6&&(t.flags|=8192)):Be(t),l=t.updateQueue,l!==null&&Mi(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==l&&(t.flags|=2048),e!==null&&L(Sa),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),Nl(Fe),Be(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function eg(e,t){switch(Wc(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Nl(Fe),Ee(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Me(t),null;case 31:if(t.memoizedState!==null){if(Ot(t),t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ot(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return L(Ke),null;case 4:return Ee(),null;case 10:return Nl(t.type),null;case 22:case 23:return Ot(t),fr(),e!==null&&L(Sa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Nl(Fe),null;case 25:return null;default:return null}}function Gd(e,t){switch(Wc(t),t.tag){case 3:Nl(Fe),Ee();break;case 26:case 27:case 5:Me(t);break;case 4:Ee();break;case 31:t.memoizedState!==null&&Ot(t);break;case 13:Ot(t);break;case 19:L(Ke);break;case 10:Nl(t.type);break;case 22:case 23:Ot(t),fr(),e!==null&&L(Sa);break;case 24:Nl(Fe)}}function cs(e,t){try{var l=t.updateQueue,a=l!==null?l.lastEffect:null;if(a!==null){var s=a.next;l=s;do{if((l.tag&e)===e){a=void 0;var r=l.create,m=l.inst;a=r(),m.destroy=a}l=l.next}while(l!==s)}}catch(b){_e(t,t.return,b)}}function Kl(e,t,l){try{var a=t.updateQueue,s=a!==null?a.lastEffect:null;if(s!==null){var r=s.next;a=r;do{if((a.tag&e)===e){var m=a.inst,b=m.destroy;if(b!==void 0){m.destroy=void 0,s=t;var j=l,C=b;try{C()}catch(R){_e(s,j,R)}}}a=a.next}while(a!==r)}}catch(R){_e(t,t.return,R)}}function $d(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{Of(t,l)}catch(a){_e(e,e.return,a)}}}function Qd(e,t,l){l.props=Aa(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(a){_e(e,t,a)}}function rs(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var a=e.stateNode;break;case 30:a=e.stateNode;break;default:a=e.stateNode}typeof l=="function"?e.refCleanup=l(a):l.current=a}}catch(s){_e(e,t,s)}}function rl(e,t){var l=e.ref,a=e.refCleanup;if(l!==null)if(typeof a=="function")try{a()}catch(s){_e(e,t,s)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(s){_e(e,t,s)}else l.current=null}function Xd(e){var t=e.type,l=e.memoizedProps,a=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&a.focus();break e;case"img":l.src?a.src=l.src:l.srcSet&&(a.srcset=l.srcSet)}}catch(s){_e(e,e.return,s)}}function $r(e,t,l){try{var a=e.stateNode;Ng(a,e.type,l,t),a[jt]=t}catch(s){_e(e,e.return,s)}}function Vd(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&ta(e.type)||e.tag===4}function Qr(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&ta(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Xr(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=gl));else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(Xr(e,t,l),e=e.sibling;e!==null;)Xr(e,t,l),e=e.sibling}function ki(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(ki(e,t,l),e=e.sibling;e!==null;)ki(e,t,l),e=e.sibling}function Zd(e){var t=e.stateNode,l=e.memoizedProps;try{for(var a=e.type,s=t.attributes;s.length;)t.removeAttributeNode(s[0]);ut(t,a,l),t[it]=e,t[jt]=l}catch(r){_e(e,e.return,r)}}var Tl=!1,et=!1,Vr=!1,Kd=typeof WeakSet=="function"?WeakSet:Set,st=null;function tg(e,t){if(e=e.containerInfo,ho=Pi,e=sf(e),Bc(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var a=l.getSelection&&l.getSelection();if(a&&a.rangeCount!==0){l=a.anchorNode;var s=a.anchorOffset,r=a.focusNode;a=a.focusOffset;try{l.nodeType,r.nodeType}catch{l=null;break e}var m=0,b=-1,j=-1,C=0,R=0,B=e,M=null;t:for(;;){for(var O;B!==l||s!==0&&B.nodeType!==3||(b=m+s),B!==r||a!==0&&B.nodeType!==3||(j=m+a),B.nodeType===3&&(m+=B.nodeValue.length),(O=B.firstChild)!==null;)M=B,B=O;for(;;){if(B===e)break t;if(M===l&&++C===s&&(b=m),M===r&&++R===a&&(j=m),(O=B.nextSibling)!==null)break;B=M,M=B.parentNode}B=O}l=b===-1||j===-1?null:{start:b,end:j}}else l=null}l=l||{start:0,end:0}}else l=null;for(po={focusedElem:e,selectionRange:l},Pi=!1,st=t;st!==null;)if(t=st,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,st=e;else for(;st!==null;){switch(t=st,r=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l<e.length;l++)s=e[l],s.ref.impl=s.nextImpl;break;case 11:case 15:break;case 1:if((e&1024)!==0&&r!==null){e=void 0,l=t,s=r.memoizedProps,r=r.memoizedState,a=l.stateNode;try{var W=Aa(l.type,s);e=a.getSnapshotBeforeUpdate(W,r),a.__reactInternalSnapshotBeforeUpdate=e}catch(ie){_e(l,l.return,ie)}}break;case 3:if((e&1024)!==0){if(e=t.stateNode.containerInfo,l=e.nodeType,l===9)bo(e);else if(l===1)switch(e.nodeName){case"HEAD":case"HTML":case"BODY":bo(e);break;default:e.textContent=""}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;default:if((e&1024)!==0)throw Error(u(163))}if(e=t.sibling,e!==null){e.return=t.return,st=e;break}st=t.return}}function Jd(e,t,l){var a=l.flags;switch(l.tag){case 0:case 11:case 15:_l(e,l),a&4&&cs(5,l);break;case 1:if(_l(e,l),a&4)if(e=l.stateNode,t===null)try{e.componentDidMount()}catch(m){_e(l,l.return,m)}else{var s=Aa(l.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(s,t,e.__reactInternalSnapshotBeforeUpdate)}catch(m){_e(l,l.return,m)}}a&64&&$d(l),a&512&&rs(l,l.return);break;case 3:if(_l(e,l),a&64&&(e=l.updateQueue,e!==null)){if(t=null,l.child!==null)switch(l.child.tag){case 27:case 5:t=l.child.stateNode;break;case 1:t=l.child.stateNode}try{Of(e,t)}catch(m){_e(l,l.return,m)}}break;case 27:t===null&&a&4&&Zd(l);case 26:case 5:_l(e,l),t===null&&a&4&&Xd(l),a&512&&rs(l,l.return);break;case 12:_l(e,l);break;case 31:_l(e,l),a&4&&Id(e,l);break;case 13:_l(e,l),a&4&&Pd(e,l),a&64&&(e=l.memoizedState,e!==null&&(e=e.dehydrated,e!==null&&(l=ug.bind(null,l),Cg(e,l))));break;case 22:if(a=l.memoizedState!==null||Tl,!a){t=t!==null&&t.memoizedState!==null||et,s=Tl;var r=et;Tl=a,(et=t)&&!r?Cl(e,l,(l.subtreeFlags&8772)!==0):_l(e,l),Tl=s,et=r}break;case 30:break;default:_l(e,l)}}function Wd(e){var t=e.alternate;t!==null&&(e.alternate=null,Wd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&Nc(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var qe=null,St=!1;function Al(e,t,l){for(l=l.child;l!==null;)Fd(e,t,l),l=l.sibling}function Fd(e,t,l){if(pt&&typeof pt.onCommitFiberUnmount=="function")try{pt.onCommitFiberUnmount(al,l)}catch{}switch(l.tag){case 26:et||rl(l,t),Al(e,t,l),l.memoizedState?l.memoizedState.count--:l.stateNode&&(l=l.stateNode,l.parentNode.removeChild(l));break;case 27:et||rl(l,t);var a=qe,s=St;ta(l.type)&&(qe=l.stateNode,St=!1),Al(e,t,l),gs(l.stateNode),qe=a,St=s;break;case 5:et||rl(l,t);case 6:if(a=qe,s=St,qe=null,Al(e,t,l),qe=a,St=s,qe!==null)if(St)try{(qe.nodeType===9?qe.body:qe.nodeName==="HTML"?qe.ownerDocument.body:qe).removeChild(l.stateNode)}catch(r){_e(l,t,r)}else try{qe.removeChild(l.stateNode)}catch(r){_e(l,t,r)}break;case 18:qe!==null&&(St?(e=qe,$m(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.stateNode),jn(e)):$m(qe,l.stateNode));break;case 4:a=qe,s=St,qe=l.stateNode.containerInfo,St=!0,Al(e,t,l),qe=a,St=s;break;case 0:case 11:case 14:case 15:Kl(2,l,t),et||Kl(4,l,t),Al(e,t,l);break;case 1:et||(rl(l,t),a=l.stateNode,typeof a.componentWillUnmount=="function"&&Qd(l,t,a)),Al(e,t,l);break;case 21:Al(e,t,l);break;case 22:et=(a=et)||l.memoizedState!==null,Al(e,t,l),et=a;break;default:Al(e,t,l)}}function Id(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null))){e=e.dehydrated;try{jn(e)}catch(l){_e(t,t.return,l)}}}function Pd(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null&&(e=e.dehydrated,e!==null))))try{jn(e)}catch(l){_e(t,t.return,l)}}function lg(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return t===null&&(t=e.stateNode=new Kd),t;case 22:return e=e.stateNode,t=e._retryCache,t===null&&(t=e._retryCache=new Kd),t;default:throw Error(u(435,e.tag))}}function Oi(e,t){var l=lg(e);t.forEach(function(a){if(!l.has(a)){l.add(a);var s=fg.bind(null,e,a);a.then(s,s)}})}function zt(e,t){var l=t.deletions;if(l!==null)for(var a=0;a<l.length;a++){var s=l[a],r=e,m=t,b=m;e:for(;b!==null;){switch(b.tag){case 27:if(ta(b.type)){qe=b.stateNode,St=!1;break e}break;case 5:qe=b.stateNode,St=!1;break e;case 3:case 4:qe=b.stateNode.containerInfo,St=!0;break e}b=b.return}if(qe===null)throw Error(u(160));Fd(r,m,s),qe=null,St=!1,r=s.alternate,r!==null&&(r.return=null),s.return=null}if(t.subtreeFlags&13886)for(t=t.child;t!==null;)em(t,e),t=t.sibling}var It=null;function em(e,t){var l=e.alternate,a=e.flags;switch(e.tag){case 0:case 11:case 14:case 15:zt(t,e),Et(e),a&4&&(Kl(3,e,e.return),cs(3,e),Kl(5,e,e.return));break;case 1:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&64&&Tl&&(e=e.updateQueue,e!==null&&(a=e.callbacks,a!==null&&(l=e.shared.hiddenCallbacks,e.shared.hiddenCallbacks=l===null?a:l.concat(a))));break;case 26:var s=It;if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&4){var r=l!==null?l.memoizedState:null;if(a=e.memoizedState,l===null)if(a===null)if(e.stateNode===null){e:{a=e.type,l=e.memoizedProps,s=s.ownerDocument||s;t:switch(a){case"title":r=s.getElementsByTagName("title")[0],(!r||r[Rn]||r[it]||r.namespaceURI==="http://www.w3.org/2000/svg"||r.hasAttribute("itemprop"))&&(r=s.createElement(a),s.head.insertBefore(r,s.querySelector("head > title"))),ut(r,a,l),r[it]=e,nt(r),a=r;break e;case"link":var m=eh("link","href",s).get(a+(l.href||""));if(m){for(var b=0;b<m.length;b++)if(r=m[b],r.getAttribute("href")===(l.href==null||l.href===""?null:l.href)&&r.getAttribute("rel")===(l.rel==null?null:l.rel)&&r.getAttribute("title")===(l.title==null?null:l.title)&&r.getAttribute("crossorigin")===(l.crossOrigin==null?null:l.crossOrigin)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;case"meta":if(m=eh("meta","content",s).get(a+(l.content||""))){for(b=0;b<m.length;b++)if(r=m[b],r.getAttribute("content")===(l.content==null?null:""+l.content)&&r.getAttribute("name")===(l.name==null?null:l.name)&&r.getAttribute("property")===(l.property==null?null:l.property)&&r.getAttribute("http-equiv")===(l.httpEquiv==null?null:l.httpEquiv)&&r.getAttribute("charset")===(l.charSet==null?null:l.charSet)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;default:throw Error(u(468,a))}r[it]=e,nt(r),a=r}e.stateNode=a}else th(s,e.type,e.stateNode);else e.stateNode=Pm(s,a,e.memoizedProps);else r!==a?(r===null?l.stateNode!==null&&(l=l.stateNode,l.parentNode.removeChild(l)):r.count--,a===null?th(s,e.type,e.stateNode):Pm(s,a,e.memoizedProps)):a===null&&e.stateNode!==null&&$r(e,e.memoizedProps,l.memoizedProps)}break;case 27:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),l!==null&&a&4&&$r(e,e.memoizedProps,l.memoizedProps);break;case 5:if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),e.flags&32){s=e.stateNode;try{$a(s,"")}catch(W){_e(e,e.return,W)}}a&4&&e.stateNode!=null&&(s=e.memoizedProps,$r(e,s,l!==null?l.memoizedProps:s)),a&1024&&(Vr=!0);break;case 6:if(zt(t,e),Et(e),a&4){if(e.stateNode===null)throw Error(u(162));a=e.memoizedProps,l=e.stateNode;try{l.nodeValue=a}catch(W){_e(e,e.return,W)}}break;case 3:if(Ji=null,s=It,It=Zi(t.containerInfo),zt(t,e),It=s,Et(e),a&4&&l!==null&&l.memoizedState.isDehydrated)try{jn(t.containerInfo)}catch(W){_e(e,e.return,W)}Vr&&(Vr=!1,tm(e));break;case 4:a=It,It=Zi(e.stateNode.containerInfo),zt(t,e),Et(e),It=a;break;case 12:zt(t,e),Et(e);break;case 31:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 13:zt(t,e),Et(e),e.child.flags&8192&&e.memoizedState!==null!=(l!==null&&l.memoizedState!==null)&&(Ri=dt()),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 22:s=e.memoizedState!==null;var j=l!==null&&l.memoizedState!==null,C=Tl,R=et;if(Tl=C||s,et=R||j,zt(t,e),et=R,Tl=C,Et(e),a&8192)e:for(t=e.stateNode,t._visibility=s?t._visibility&-2:t._visibility|1,s&&(l===null||j||Tl||et||_a(e)),l=null,t=e;;){if(t.tag===5||t.tag===26){if(l===null){j=l=t;try{if(r=j.stateNode,s)m=r.style,typeof m.setProperty=="function"?m.setProperty("display","none","important"):m.display="none";else{b=j.stateNode;var B=j.memoizedProps.style,M=B!=null&&B.hasOwnProperty("display")?B.display:null;b.style.display=M==null||typeof M=="boolean"?"":(""+M).trim()}}catch(W){_e(j,j.return,W)}}}else if(t.tag===6){if(l===null){j=t;try{j.stateNode.nodeValue=s?"":j.memoizedProps}catch(W){_e(j,j.return,W)}}}else if(t.tag===18){if(l===null){j=t;try{var O=j.stateNode;s?Qm(O,!0):Qm(j.stateNode,!1)}catch(W){_e(j,j.return,W)}}}else if((t.tag!==22&&t.tag!==23||t.memoizedState===null||t===e)&&t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break e;for(;t.sibling===null;){if(t.return===null||t.return===e)break e;l===t&&(l=null),t=t.return}l===t&&(l=null),t.sibling.return=t.return,t=t.sibling}a&4&&(a=e.updateQueue,a!==null&&(l=a.retryQueue,l!==null&&(a.retryQueue=null,Oi(e,l))));break;case 19:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 30:break;case 21:break;default:zt(t,e),Et(e)}}function Et(e){var t=e.flags;if(t&2){try{for(var l,a=e.return;a!==null;){if(Vd(a)){l=a;break}a=a.return}if(l==null)throw Error(u(160));switch(l.tag){case 27:var s=l.stateNode,r=Qr(e);ki(e,r,s);break;case 5:var m=l.stateNode;l.flags&32&&($a(m,""),l.flags&=-33);var b=Qr(e);ki(e,b,m);break;case 3:case 4:var j=l.stateNode.containerInfo,C=Qr(e);Xr(e,C,j);break;default:throw Error(u(161))}}catch(R){_e(e,e.return,R)}e.flags&=-3}t&4096&&(e.flags&=-4097)}function tm(e){if(e.subtreeFlags&1024)for(e=e.child;e!==null;){var t=e;tm(t),t.tag===5&&t.flags&1024&&t.stateNode.reset(),e=e.sibling}}function _l(e,t){if(t.subtreeFlags&8772)for(t=t.child;t!==null;)Jd(e,t.alternate,t),t=t.sibling}function _a(e){for(e=e.child;e!==null;){var t=e;switch(t.tag){case 0:case 11:case 14:case 15:Kl(4,t,t.return),_a(t);break;case 1:rl(t,t.return);var l=t.stateNode;typeof l.componentWillUnmount=="function"&&Qd(t,t.return,l),_a(t);break;case 27:gs(t.stateNode);case 26:case 5:rl(t,t.return),_a(t);break;case 22:t.memoizedState===null&&_a(t);break;case 30:_a(t);break;default:_a(t)}e=e.sibling}}function Cl(e,t,l){for(l=l&&(t.subtreeFlags&8772)!==0,t=t.child;t!==null;){var a=t.alternate,s=e,r=t,m=r.flags;switch(r.tag){case 0:case 11:case 15:Cl(s,r,l),cs(4,r);break;case 1:if(Cl(s,r,l),a=r,s=a.stateNode,typeof s.componentDidMount=="function")try{s.componentDidMount()}catch(C){_e(a,a.return,C)}if(a=r,s=a.updateQueue,s!==null){var b=a.stateNode;try{var j=s.shared.hiddenCallbacks;if(j!==null)for(s.shared.hiddenCallbacks=null,s=0;s<j.length;s++)kf(j[s],b)}catch(C){_e(a,a.return,C)}}l&&m&64&&$d(r),rs(r,r.return);break;case 27:Zd(r);case 26:case 5:Cl(s,r,l),l&&a===null&&m&4&&Xd(r),rs(r,r.return);break;case 12:Cl(s,r,l);break;case 31:Cl(s,r,l),l&&m&4&&Id(s,r);break;case 13:Cl(s,r,l),l&&m&4&&Pd(s,r);break;case 22:r.memoizedState===null&&Cl(s,r,l),rs(r,r.return);break;case 30:break;default:Cl(s,r,l)}t=t.sibling}}function Zr(e,t){var l=null;e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==l&&(e!=null&&e.refCount++,l!=null&&Kn(l))}function Kr(e,t){e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e))}function Pt(e,t,l,a){if(t.subtreeFlags&10256)for(t=t.child;t!==null;)lm(e,t,l,a),t=t.sibling}function lm(e,t,l,a){var s=t.flags;switch(t.tag){case 0:case 11:case 15:Pt(e,t,l,a),s&2048&&cs(9,t);break;case 1:Pt(e,t,l,a);break;case 3:Pt(e,t,l,a),s&2048&&(e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e)));break;case 12:if(s&2048){Pt(e,t,l,a),e=t.stateNode;try{var r=t.memoizedProps,m=r.id,b=r.onPostCommit;typeof b=="function"&&b(m,t.alternate===null?"mount":"update",e.passiveEffectDuration,-0)}catch(j){_e(t,t.return,j)}}else Pt(e,t,l,a);break;case 31:Pt(e,t,l,a);break;case 13:Pt(e,t,l,a);break;case 23:break;case 22:r=t.stateNode,m=t.alternate,t.memoizedState!==null?r._visibility&2?Pt(e,t,l,a):os(e,t):r._visibility&2?Pt(e,t,l,a):(r._visibility|=2,un(e,t,l,a,(t.subtreeFlags&10256)!==0||!1)),s&2048&&Zr(m,t);break;case 24:Pt(e,t,l,a),s&2048&&Kr(t.alternate,t);break;default:Pt(e,t,l,a)}}function un(e,t,l,a,s){for(s=s&&((t.subtreeFlags&10256)!==0||!1),t=t.child;t!==null;){var r=e,m=t,b=l,j=a,C=m.flags;switch(m.tag){case 0:case 11:case 15:un(r,m,b,j,s),cs(8,m);break;case 23:break;case 22:var R=m.stateNode;m.memoizedState!==null?R._visibility&2?un(r,m,b,j,s):os(r,m):(R._visibility|=2,un(r,m,b,j,s)),s&&C&2048&&Zr(m.alternate,m);break;case 24:un(r,m,b,j,s),s&&C&2048&&Kr(m.alternate,m);break;default:un(r,m,b,j,s)}t=t.sibling}}function os(e,t){if(t.subtreeFlags&10256)for(t=t.child;t!==null;){var l=e,a=t,s=a.flags;switch(a.tag){case 22:os(l,a),s&2048&&Zr(a.alternate,a);break;case 24:os(l,a),s&2048&&Kr(a.alternate,a);break;default:os(l,a)}t=t.sibling}}var us=8192;function fn(e,t,l){if(e.subtreeFlags&us)for(e=e.child;e!==null;)am(e,t,l),e=e.sibling}function am(e,t,l){switch(e.tag){case 26:fn(e,t,l),e.flags&us&&e.memoizedState!==null&&Gg(l,It,e.memoizedState,e.memoizedProps);break;case 5:fn(e,t,l);break;case 3:case 4:var a=It;It=Zi(e.stateNode.containerInfo),fn(e,t,l),It=a;break;case 22:e.memoizedState===null&&(a=e.alternate,a!==null&&a.memoizedState!==null?(a=us,us=16777216,fn(e,t,l),us=a):fn(e,t,l));break;default:fn(e,t,l)}}function nm(e){var t=e.alternate;if(t!==null&&(e=t.child,e!==null)){t.child=null;do t=e.sibling,e.sibling=null,e=t;while(e!==null)}}function fs(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}if(e.subtreeFlags&10256)for(e=e.child;e!==null;)sm(e),e=e.sibling}function sm(e){switch(e.tag){case 0:case 11:case 15:fs(e),e.flags&2048&&Kl(9,e,e.return);break;case 3:fs(e);break;case 12:fs(e);break;case 22:var t=e.stateNode;e.memoizedState!==null&&t._visibility&2&&(e.return===null||e.return.tag!==13)?(t._visibility&=-3,Di(e)):fs(e);break;default:fs(e)}}function Di(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}for(e=e.child;e!==null;){switch(t=e,t.tag){case 0:case 11:case 15:Kl(8,t,t.return),Di(t);break;case 22:l=t.stateNode,l._visibility&2&&(l._visibility&=-3,Di(t));break;default:Di(t)}e=e.sibling}}function im(e,t){for(;st!==null;){var l=st;switch(l.tag){case 0:case 11:case 15:Kl(8,l,t);break;case 23:case 22:if(l.memoizedState!==null&&l.memoizedState.cachePool!==null){var a=l.memoizedState.cachePool.pool;a!=null&&a.refCount++}break;case 24:Kn(l.memoizedState.cache)}if(a=l.child,a!==null)a.return=l,st=a;else e:for(l=e;st!==null;){a=st;var s=a.sibling,r=a.return;if(Wd(a),a===l){st=null;break e}if(s!==null){s.return=r,st=s;break e}st=r}}}var ag={getCacheForType:function(e){var t=rt(Fe),l=t.data.get(e);return l===void 0&&(l=e(),t.data.set(e,l)),l},cacheSignal:function(){return rt(Fe).controller.signal}},ng=typeof WeakMap=="function"?WeakMap:Map,we=0,Ue=null,pe=null,ge=0,Ae=0,Dt=null,Jl=!1,dn=!1,Jr=!1,Ml=0,Ve=0,Wl=0,Ca=0,Wr=0,Rt=0,mn=0,ds=null,wt=null,Fr=!1,Ri=0,cm=0,Ui=1/0,Hi=null,Fl=null,lt=0,Il=null,hn=null,kl=0,Ir=0,Pr=null,rm=null,ms=0,eo=null;function Ut(){return(we&2)!==0&&ge!==0?ge&-ge:k.T!==null?io():Su()}function om(){if(Rt===0)if((ge&536870912)===0||ve){var e=da;da<<=1,(da&3932160)===0&&(da=262144),Rt=e}else Rt=536870912;return e=kt.current,e!==null&&(e.flags|=32),Rt}function Tt(e,t,l){(e===Ue&&(Ae===2||Ae===9)||e.cancelPendingCommit!==null)&&(pn(e,0),Pl(e,ge,Rt,!1)),Dn(e,l),((we&2)===0||e!==Ue)&&(e===Ue&&((we&2)===0&&(Ca|=l),Ve===4&&Pl(e,ge,Rt,!1)),ol(e))}function um(e,t,l){if((we&6)!==0)throw Error(u(327));var a=!l&&(t&127)===0&&(t&e.expiredLanes)===0||Ul(e,t),s=a?cg(e,t):lo(e,t,!0),r=a;do{if(s===0){dn&&!a&&Pl(e,t,0,!1);break}else{if(l=e.current.alternate,r&&!sg(l)){s=lo(e,t,!1),r=!1;continue}if(s===2){if(r=t,e.errorRecoveryDisabledLanes&r)var m=0;else m=e.pendingLanes&-536870913,m=m!==0?m:m&536870912?536870912:0;if(m!==0){t=m;e:{var b=e;s=ds;var j=b.current.memoizedState.isDehydrated;if(j&&(pn(b,m).flags|=256),m=lo(b,m,!1),m!==2){if(Jr&&!j){b.errorRecoveryDisabledLanes|=r,Ca|=r,s=4;break e}r=wt,wt=s,r!==null&&(wt===null?wt=r:wt.push.apply(wt,r))}s=m}if(r=!1,s!==2)continue}}if(s===1){pn(e,0),Pl(e,t,0,!0);break}e:{switch(a=e,r=s,r){case 0:case 1:throw Error(u(345));case 4:if((t&4194048)!==t)break;case 6:Pl(a,t,Rt,!Jl);break e;case 2:wt=null;break;case 3:case 5:break;default:throw Error(u(329))}if((t&62914560)===t&&(s=Ri+300-dt(),10<s)){if(Pl(a,t,Rt,!Jl),Ua(a,0,!0)!==0)break e;kl=t,a.timeoutHandle=Ym(fm.bind(null,a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,"Throttled",-0,0),s);break e}fm(a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,null,-0,0)}}break}while(!0);ol(e)}function fm(e,t,l,a,s,r,m,b,j,C,R,B,M,O){if(e.timeoutHandle=-1,B=t.subtreeFlags,B&8192||(B&16785408)===16785408){B={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:gl},am(t,r,B);var W=(r&62914560)===r?Ri-dt():(r&4194048)===r?cm-dt():0;if(W=$g(B,W),W!==null){kl=r,e.cancelPendingCommit=W(vm.bind(null,e,t,r,l,a,s,m,b,j,R,B,null,M,O)),Pl(e,r,m,!C);return}}vm(e,t,r,l,a,s,m,b,j)}function sg(e){for(var t=e;;){var l=t.tag;if((l===0||l===11||l===15)&&t.flags&16384&&(l=t.updateQueue,l!==null&&(l=l.stores,l!==null)))for(var a=0;a<l.length;a++){var s=l[a],r=s.getSnapshot;s=s.value;try{if(!Ct(r(),s))return!1}catch{return!1}}if(l=t.child,t.subtreeFlags&16384&&l!==null)l.return=t,t=l;else{if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return!0;t=t.return}t.sibling.return=t.return,t=t.sibling}}return!0}function Pl(e,t,l,a){t&=~Wr,t&=~Ca,e.suspendedLanes|=t,e.pingedLanes&=~t,a&&(e.warmLanes|=t),a=e.expirationTimes;for(var s=t;0<s;){var r=31-xt(s),m=1<<r;a[r]=-1,s&=~m}l!==0&&yu(e,l,t)}function Li(){return(we&6)===0?(hs(0),!1):!0}function to(){if(pe!==null){if(Ae===0)var e=pe.return;else e=pe,jl=ja=null,gr(e),nn=null,Wn=0,e=pe;for(;e!==null;)Gd(e.alternate,e),e=e.return;pe=null}}function pn(e,t){var l=e.timeoutHandle;l!==-1&&(e.timeoutHandle=-1,Eg(l)),l=e.cancelPendingCommit,l!==null&&(e.cancelPendingCommit=null,l()),kl=0,to(),Ue=e,pe=l=vl(e.current,null),ge=t,Ae=0,Dt=null,Jl=!1,dn=Ul(e,t),Jr=!1,mn=Rt=Wr=Ca=Wl=Ve=0,wt=ds=null,Fr=!1,(t&8)!==0&&(t|=t&32);var a=e.entangledLanes;if(a!==0)for(e=e.entanglements,a&=t;0<a;){var s=31-xt(a),r=1<<s;t|=e[s],a&=~r}return Ml=t,si(),l}function dm(e,t){de=null,k.H=ns,t===an||t===mi?(t=Af(),Ae=3):t===sr?(t=Af(),Ae=4):Ae=t===Or?8:t!==null&&typeof t=="object"&&typeof t.then=="function"?6:1,Dt=t,pe===null&&(Ve=1,Ti(e,Yt(t,e.current)))}function mm(){var e=kt.current;return e===null?!0:(ge&4194048)===ge?Xt===null:(ge&62914560)===ge||(ge&536870912)!==0?e===Xt:!1}function hm(){var e=k.H;return k.H=ns,e===null?ns:e}function pm(){var e=k.A;return k.A=ag,e}function Bi(){Ve=4,Jl||(ge&4194048)!==ge&&kt.current!==null||(dn=!0),(Wl&134217727)===0&&(Ca&134217727)===0||Ue===null||Pl(Ue,ge,Rt,!1)}function lo(e,t,l){var a=we;we|=2;var s=hm(),r=pm();(Ue!==e||ge!==t)&&(Hi=null,pn(e,t)),t=!1;var m=Ve;e:do try{if(Ae!==0&&pe!==null){var b=pe,j=Dt;switch(Ae){case 8:to(),m=6;break e;case 3:case 2:case 9:case 6:kt.current===null&&(t=!0);var C=Ae;if(Ae=0,Dt=null,xn(e,b,j,C),l&&dn){m=0;break e}break;default:C=Ae,Ae=0,Dt=null,xn(e,b,j,C)}}ig(),m=Ve;break}catch(R){dm(e,R)}while(!0);return t&&e.shellSuspendCounter++,jl=ja=null,we=a,k.H=s,k.A=r,pe===null&&(Ue=null,ge=0,si()),m}function ig(){for(;pe!==null;)xm(pe)}function cg(e,t){var l=we;we|=2;var a=hm(),s=pm();Ue!==e||ge!==t?(Hi=null,Ui=dt()+500,pn(e,t)):dn=Ul(e,t);e:do try{if(Ae!==0&&pe!==null){t=pe;var r=Dt;t:switch(Ae){case 1:Ae=0,Dt=null,xn(e,t,r,1);break;case 2:case 9:if(wf(r)){Ae=0,Dt=null,gm(t);break}t=function(){Ae!==2&&Ae!==9||Ue!==e||(Ae=7),ol(e)},r.then(t,t);break e;case 3:Ae=7;break e;case 4:Ae=5;break e;case 7:wf(r)?(Ae=0,Dt=null,gm(t)):(Ae=0,Dt=null,xn(e,t,r,7));break;case 5:var m=null;switch(pe.tag){case 26:m=pe.memoizedState;case 5:case 27:var b=pe;if(m?lh(m):b.stateNode.complete){Ae=0,Dt=null;var j=b.sibling;if(j!==null)pe=j;else{var C=b.return;C!==null?(pe=C,qi(C)):pe=null}break t}}Ae=0,Dt=null,xn(e,t,r,5);break;case 6:Ae=0,Dt=null,xn(e,t,r,6);break;case 8:to(),Ve=6;break e;default:throw Error(u(462))}}rg();break}catch(R){dm(e,R)}while(!0);return jl=ja=null,k.H=a,k.A=s,we=l,pe!==null?0:(Ue=null,ge=0,si(),Ve)}function rg(){for(;pe!==null&&!An();)xm(pe)}function xm(e){var t=qd(e.alternate,e,Ml);e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function gm(e){var t=e,l=t.alternate;switch(t.tag){case 15:case 0:t=Dd(l,t,t.pendingProps,t.type,void 0,ge);break;case 11:t=Dd(l,t,t.pendingProps,t.type.render,t.ref,ge);break;case 5:gr(t);default:Gd(l,t),t=pe=pf(t,Ml),t=qd(l,t,Ml)}e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function xn(e,t,l,a){jl=ja=null,gr(t),nn=null,Wn=0;var s=t.return;try{if(Wx(e,s,t,l,ge)){Ve=1,Ti(e,Yt(l,e.current)),pe=null;return}}catch(r){if(s!==null)throw pe=s,r;Ve=1,Ti(e,Yt(l,e.current)),pe=null;return}t.flags&32768?(ve||a===1?e=!0:dn||(ge&536870912)!==0?e=!1:(Jl=e=!0,(a===2||a===9||a===3||a===6)&&(a=kt.current,a!==null&&a.tag===13&&(a.flags|=16384))),bm(t,e)):qi(t)}function qi(e){var t=e;do{if((t.flags&32768)!==0){bm(t,Jl);return}e=t.return;var l=Px(t.alternate,t,Ml);if(l!==null){pe=l;return}if(t=t.sibling,t!==null){pe=t;return}pe=t=e}while(t!==null);Ve===0&&(Ve=5)}function bm(e,t){do{var l=eg(e.alternate,e);if(l!==null){l.flags&=32767,pe=l;return}if(l=e.return,l!==null&&(l.flags|=32768,l.subtreeFlags=0,l.deletions=null),!t&&(e=e.sibling,e!==null)){pe=e;return}pe=e=l}while(e!==null);Ve=6,pe=null}function vm(e,t,l,a,s,r,m,b,j){e.cancelPendingCommit=null;do Yi();while(lt!==0);if((we&6)!==0)throw Error(u(327));if(t!==null){if(t===e.current)throw Error(u(177));if(r=t.lanes|t.childLanes,r|=Qc,Yp(e,l,r,m,b,j),e===Ue&&(pe=Ue=null,ge=0),hn=t,Il=e,kl=l,Ir=r,Pr=s,rm=a,(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?(e.callbackNode=null,e.callbackPriority=0,dg(Da,function(){return zm(),null})):(e.callbackNode=null,e.callbackPriority=0),a=(t.flags&13878)!==0,(t.subtreeFlags&13878)!==0||a){a=k.T,k.T=null,s=Y.p,Y.p=2,m=we,we|=4;try{tg(e,t,l)}finally{we=m,Y.p=s,k.T=a}}lt=1,ym(),jm(),Nm()}}function ym(){if(lt===1){lt=0;var e=Il,t=hn,l=(t.flags&13878)!==0;if((t.subtreeFlags&13878)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{em(t,e);var r=po,m=sf(e.containerInfo),b=r.focusedElem,j=r.selectionRange;if(m!==b&&b&&b.ownerDocument&&nf(b.ownerDocument.documentElement,b)){if(j!==null&&Bc(b)){var C=j.start,R=j.end;if(R===void 0&&(R=C),"selectionStart"in b)b.selectionStart=C,b.selectionEnd=Math.min(R,b.value.length);else{var B=b.ownerDocument||document,M=B&&B.defaultView||window;if(M.getSelection){var O=M.getSelection(),W=b.textContent.length,ie=Math.min(j.start,W),Re=j.end===void 0?ie:Math.min(j.end,W);!O.extend&&ie>Re&&(m=Re,Re=ie,ie=m);var T=af(b,ie),E=af(b,Re);if(T&&E&&(O.rangeCount!==1||O.anchorNode!==T.node||O.anchorOffset!==T.offset||O.focusNode!==E.node||O.focusOffset!==E.offset)){var _=B.createRange();_.setStart(T.node,T.offset),O.removeAllRanges(),ie>Re?(O.addRange(_),O.extend(E.node,E.offset)):(_.setEnd(E.node,E.offset),O.addRange(_))}}}}for(B=[],O=b;O=O.parentNode;)O.nodeType===1&&B.push({element:O,left:O.scrollLeft,top:O.scrollTop});for(typeof b.focus=="function"&&b.focus(),b=0;b<B.length;b++){var H=B[b];H.element.scrollLeft=H.left,H.element.scrollTop=H.top}}Pi=!!ho,po=ho=null}finally{we=s,Y.p=a,k.T=l}}e.current=t,lt=2}}function jm(){if(lt===2){lt=0;var e=Il,t=hn,l=(t.flags&8772)!==0;if((t.subtreeFlags&8772)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{Jd(e,t.alternate,t)}finally{we=s,Y.p=a,k.T=l}}lt=3}}function Nm(){if(lt===4||lt===3){lt=0,_n();var e=Il,t=hn,l=kl,a=rm;(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?lt=5:(lt=0,hn=Il=null,Sm(e,e.pendingLanes));var s=e.pendingLanes;if(s===0&&(Fl=null),yc(l),t=t.stateNode,pt&&typeof pt.onCommitFiberRoot=="function")try{pt.onCommitFiberRoot(al,t,void 0,(t.current.flags&128)===128)}catch{}if(a!==null){t=k.T,s=Y.p,Y.p=2,k.T=null;try{for(var r=e.onRecoverableError,m=0;m<a.length;m++){var b=a[m];r(b.value,{componentStack:b.stack})}}finally{k.T=t,Y.p=s}}(kl&3)!==0&&Yi(),ol(e),s=e.pendingLanes,(l&261930)!==0&&(s&42)!==0?e===eo?ms++:(ms=0,eo=e):ms=0,hs(0)}}function Sm(e,t){(e.pooledCacheLanes&=t)===0&&(t=e.pooledCache,t!=null&&(e.pooledCache=null,Kn(t)))}function Yi(){return ym(),jm(),Nm(),zm()}function zm(){if(lt!==5)return!1;var e=Il,t=Ir;Ir=0;var l=yc(kl),a=k.T,s=Y.p;try{Y.p=32>l?32:l,k.T=null,l=Pr,Pr=null;var r=Il,m=kl;if(lt=0,hn=Il=null,kl=0,(we&6)!==0)throw Error(u(331));var b=we;if(we|=4,sm(r.current),lm(r,r.current,m,l),we=b,hs(0,!1),pt&&typeof pt.onPostCommitFiberRoot=="function")try{pt.onPostCommitFiberRoot(al,r)}catch{}return!0}finally{Y.p=s,k.T=a,Sm(e,t)}}function Em(e,t,l){t=Yt(l,t),t=kr(e.stateNode,t,2),e=Xl(e,t,2),e!==null&&(Dn(e,2),ol(e))}function _e(e,t,l){if(e.tag===3)Em(e,e,l);else for(;t!==null;){if(t.tag===3){Em(t,e,l);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Fl===null||!Fl.has(a))){e=Yt(l,e),l=wd(2),a=Xl(t,l,2),a!==null&&(Td(l,a,t,e),Dn(a,2),ol(a));break}}t=t.return}}function ao(e,t,l){var a=e.pingCache;if(a===null){a=e.pingCache=new ng;var s=new Set;a.set(t,s)}else s=a.get(t),s===void 0&&(s=new Set,a.set(t,s));s.has(l)||(Jr=!0,s.add(l),e=og.bind(null,e,t,l),t.then(e,e))}function og(e,t,l){var a=e.pingCache;a!==null&&a.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,Ue===e&&(ge&l)===l&&(Ve===4||Ve===3&&(ge&62914560)===ge&&300>dt()-Ri?(we&2)===0&&pn(e,0):Wr|=l,mn===ge&&(mn=0)),ol(e)}function wm(e,t){t===0&&(t=Zs()),e=ba(e,t),e!==null&&(Dn(e,t),ol(e))}function ug(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),wm(e,l)}function fg(e,t){var l=0;switch(e.tag){case 31:case 13:var a=e.stateNode,s=e.memoizedState;s!==null&&(l=s.retryLane);break;case 19:a=e.stateNode;break;case 22:a=e.stateNode._retryCache;break;default:throw Error(u(314))}a!==null&&a.delete(t),wm(e,l)}function dg(e,t){return Wt(e,t)}var Gi=null,gn=null,no=!1,$i=!1,so=!1,ea=0;function ol(e){e!==gn&&e.next===null&&(gn===null?Gi=gn=e:gn=gn.next=e),$i=!0,no||(no=!0,hg())}function hs(e,t){if(!so&&$i){so=!0;do for(var l=!1,a=Gi;a!==null;){if(e!==0){var s=a.pendingLanes;if(s===0)var r=0;else{var m=a.suspendedLanes,b=a.pingedLanes;r=(1<<31-xt(42|e)+1)-1,r&=s&~(m&~b),r=r&201326741?r&201326741|1:r?r|2:0}r!==0&&(l=!0,Cm(a,r))}else r=ge,r=Ua(a,a===Ue?r:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(r&3)===0||Ul(a,r)||(l=!0,Cm(a,r));a=a.next}while(l);so=!1}}function mg(){Tm()}function Tm(){$i=no=!1;var e=0;ea!==0&&zg()&&(e=ea);for(var t=dt(),l=null,a=Gi;a!==null;){var s=a.next,r=Am(a,t);r===0?(a.next=null,l===null?Gi=s:l.next=s,s===null&&(gn=l)):(l=a,(e!==0||(r&3)!==0)&&($i=!0)),a=s}lt!==0&<!==5||hs(e),ea!==0&&(ea=0)}function Am(e,t){for(var l=e.suspendedLanes,a=e.pingedLanes,s=e.expirationTimes,r=e.pendingLanes&-62914561;0<r;){var m=31-xt(r),b=1<<m,j=s[m];j===-1?((b&l)===0||(b&a)!==0)&&(s[m]=Vs(b,t)):j<=t&&(e.expiredLanes|=b),r&=~b}if(t=Ue,l=ge,l=Ua(e,e===t?l:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a=e.callbackNode,l===0||e===t&&(Ae===2||Ae===9)||e.cancelPendingCommit!==null)return a!==null&&a!==null&&hl(a),e.callbackNode=null,e.callbackPriority=0;if((l&3)===0||Ul(e,l)){if(t=l&-l,t===e.callbackPriority)return t;switch(a!==null&&hl(a),yc(l)){case 2:case 8:l=Cn;break;case 32:l=Da;break;case 268435456:l=pl;break;default:l=Da}return a=_m.bind(null,e),l=Wt(l,a),e.callbackPriority=t,e.callbackNode=l,t}return a!==null&&a!==null&&hl(a),e.callbackPriority=2,e.callbackNode=null,2}function _m(e,t){if(lt!==0&<!==5)return e.callbackNode=null,e.callbackPriority=0,null;var l=e.callbackNode;if(Yi()&&e.callbackNode!==l)return null;var a=ge;return a=Ua(e,e===Ue?a:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a===0?null:(um(e,a,t),Am(e,dt()),e.callbackNode!=null&&e.callbackNode===l?_m.bind(null,e):null)}function Cm(e,t){if(Yi())return null;um(e,t,!0)}function hg(){wg(function(){(we&6)!==0?Wt(Qs,mg):Tm()})}function io(){if(ea===0){var e=tn;e===0&&(e=Ra,Ra<<=1,(Ra&261888)===0&&(Ra=256)),ea=e}return ea}function Mm(e){return e==null||typeof e=="symbol"||typeof e=="boolean"?null:typeof e=="function"?e:Fs(""+e)}function km(e,t){var l=t.ownerDocument.createElement("input");return l.name=t.name,l.value=t.value,e.id&&l.setAttribute("form",e.id),t.parentNode.insertBefore(l,t),e=new FormData(e),l.parentNode.removeChild(l),e}function pg(e,t,l,a,s){if(t==="submit"&&l&&l.stateNode===s){var r=Mm((s[jt]||null).action),m=a.submitter;m&&(t=(t=m[jt]||null)?Mm(t.formAction):m.getAttribute("formAction"),t!==null&&(r=t,m=null));var b=new ti("action","action",null,a,s);e.push({event:b,listeners:[{instance:null,listener:function(){if(a.defaultPrevented){if(ea!==0){var j=m?km(s,m):new FormData(s);wr(l,{pending:!0,data:j,method:s.method,action:r},null,j)}}else typeof r=="function"&&(b.preventDefault(),j=m?km(s,m):new FormData(s),wr(l,{pending:!0,data:j,method:s.method,action:r},r,j))},currentTarget:s}]})}}for(var co=0;co<$c.length;co++){var ro=$c[co],xg=ro.toLowerCase(),gg=ro[0].toUpperCase()+ro.slice(1);Ft(xg,"on"+gg)}Ft(of,"onAnimationEnd"),Ft(uf,"onAnimationIteration"),Ft(ff,"onAnimationStart"),Ft("dblclick","onDoubleClick"),Ft("focusin","onFocus"),Ft("focusout","onBlur"),Ft(Ox,"onTransitionRun"),Ft(Dx,"onTransitionStart"),Ft(Rx,"onTransitionCancel"),Ft(df,"onTransitionEnd"),Ya("onMouseEnter",["mouseout","mouseover"]),Ya("onMouseLeave",["mouseout","mouseover"]),Ya("onPointerEnter",["pointerout","pointerover"]),Ya("onPointerLeave",["pointerout","pointerover"]),ha("onChange","change click focusin focusout input keydown keyup selectionchange".split(" ")),ha("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" ")),ha("onBeforeInput",["compositionend","keypress","textInput","paste"]),ha("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));var ps="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),bg=new Set("beforetoggle cancel close invalid load scroll scrollend toggle".split(" ").concat(ps));function Om(e,t){t=(t&4)!==0;for(var l=0;l<e.length;l++){var a=e[l],s=a.event;a=a.listeners;e:{var r=void 0;if(t)for(var m=a.length-1;0<=m;m--){var b=a[m],j=b.instance,C=b.currentTarget;if(b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}else for(m=0;m<a.length;m++){if(b=a[m],j=b.instance,C=b.currentTarget,b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}}}}function xe(e,t){var l=t[jc];l===void 0&&(l=t[jc]=new Set);var a=e+"__bubble";l.has(a)||(Dm(t,e,2,!1),l.add(a))}function oo(e,t,l){var a=0;t&&(a|=4),Dm(l,e,a,t)}var Qi="_reactListening"+Math.random().toString(36).slice(2);function uo(e){if(!e[Qi]){e[Qi]=!0,wu.forEach(function(l){l!=="selectionchange"&&(bg.has(l)||oo(l,!1,e),oo(l,!0,e))});var t=e.nodeType===9?e:e.ownerDocument;t===null||t[Qi]||(t[Qi]=!0,oo("selectionchange",!1,t))}}function Dm(e,t,l,a){switch(oh(t)){case 2:var s=Vg;break;case 8:s=Zg;break;default:s=wo}l=s.bind(null,t,l,e),s=void 0,!Cc||t!=="touchstart"&&t!=="touchmove"&&t!=="wheel"||(s=!0),a?s!==void 0?e.addEventListener(t,l,{capture:!0,passive:s}):e.addEventListener(t,l,!0):s!==void 0?e.addEventListener(t,l,{passive:s}):e.addEventListener(t,l,!1)}function fo(e,t,l,a,s){var r=a;if((t&1)===0&&(t&2)===0&&a!==null)e:for(;;){if(a===null)return;var m=a.tag;if(m===3||m===4){var b=a.stateNode.containerInfo;if(b===s)break;if(m===4)for(m=a.return;m!==null;){var j=m.tag;if((j===3||j===4)&&m.stateNode.containerInfo===s)return;m=m.return}for(;b!==null;){if(m=La(b),m===null)return;if(j=m.tag,j===5||j===6||j===26||j===27){a=r=m;continue e}b=b.parentNode}}a=a.return}Lu(function(){var C=r,R=Ac(l),B=[];e:{var M=mf.get(e);if(M!==void 0){var O=ti,W=e;switch(e){case"keypress":if(Ps(l)===0)break e;case"keydown":case"keyup":O=fx;break;case"focusin":W="focus",O=Dc;break;case"focusout":W="blur",O=Dc;break;case"beforeblur":case"afterblur":O=Dc;break;case"click":if(l.button===2)break e;case"auxclick":case"dblclick":case"mousedown":case"mousemove":case"mouseup":case"mouseout":case"mouseover":case"contextmenu":O=Yu;break;case"drag":case"dragend":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"dragstart":case"drop":O=Pp;break;case"touchcancel":case"touchend":case"touchmove":case"touchstart":O=hx;break;case of:case uf:case ff:O=lx;break;case df:O=xx;break;case"scroll":case"scrollend":O=Fp;break;case"wheel":O=bx;break;case"copy":case"cut":case"paste":O=nx;break;case"gotpointercapture":case"lostpointercapture":case"pointercancel":case"pointerdown":case"pointermove":case"pointerout":case"pointerover":case"pointerup":O=$u;break;case"toggle":case"beforetoggle":O=yx}var ie=(t&4)!==0,Re=!ie&&(e==="scroll"||e==="scrollend"),T=ie?M!==null?M+"Capture":null:M;ie=[];for(var E=C,_;E!==null;){var H=E;if(_=H.stateNode,H=H.tag,H!==5&&H!==26&&H!==27||_===null||T===null||(H=Hn(E,T),H!=null&&ie.push(xs(E,H,_))),Re)break;E=E.return}0<ie.length&&(M=new O(M,W,null,l,R),B.push({event:M,listeners:ie}))}}if((t&7)===0){e:{if(M=e==="mouseover"||e==="pointerover",O=e==="mouseout"||e==="pointerout",M&&l!==Tc&&(W=l.relatedTarget||l.fromElement)&&(La(W)||W[Ha]))break e;if((O||M)&&(M=R.window===R?R:(M=R.ownerDocument)?M.defaultView||M.parentWindow:window,O?(W=l.relatedTarget||l.toElement,O=C,W=W?La(W):null,W!==null&&(Re=d(W),ie=W.tag,W!==Re||ie!==5&&ie!==27&&ie!==6)&&(W=null)):(O=null,W=C),O!==W)){if(ie=Yu,H="onMouseLeave",T="onMouseEnter",E="mouse",(e==="pointerout"||e==="pointerover")&&(ie=$u,H="onPointerLeave",T="onPointerEnter",E="pointer"),Re=O==null?M:Un(O),_=W==null?M:Un(W),M=new ie(H,E+"leave",O,l,R),M.target=Re,M.relatedTarget=_,H=null,La(R)===C&&(ie=new ie(T,E+"enter",W,l,R),ie.target=_,ie.relatedTarget=Re,H=ie),Re=H,O&&W)t:{for(ie=vg,T=O,E=W,_=0,H=T;H;H=ie(H))_++;H=0;for(var ne=E;ne;ne=ie(ne))H++;for(;0<_-H;)T=ie(T),_--;for(;0<H-_;)E=ie(E),H--;for(;_--;){if(T===E||E!==null&&T===E.alternate){ie=T;break t}T=ie(T),E=ie(E)}ie=null}else ie=null;O!==null&&Rm(B,M,O,ie,!1),W!==null&&Re!==null&&Rm(B,Re,W,ie,!0)}}e:{if(M=C?Un(C):window,O=M.nodeName&&M.nodeName.toLowerCase(),O==="select"||O==="input"&&M.type==="file")var Ne=Fu;else if(Ju(M))if(Iu)Ne=Cx;else{Ne=Ax;var F=Tx}else O=M.nodeName,!O||O.toLowerCase()!=="input"||M.type!=="checkbox"&&M.type!=="radio"?C&&wc(C.elementType)&&(Ne=Fu):Ne=_x;if(Ne&&(Ne=Ne(e,C))){Wu(B,Ne,l,R);break e}F&&F(e,M,C),e==="focusout"&&C&&M.type==="number"&&C.memoizedProps.value!=null&&Ec(M,"number",M.value)}switch(F=C?Un(C):window,e){case"focusin":(Ju(F)||F.contentEditable==="true")&&(Za=F,qc=C,Xn=null);break;case"focusout":Xn=qc=Za=null;break;case"mousedown":Yc=!0;break;case"contextmenu":case"mouseup":case"dragend":Yc=!1,cf(B,l,R);break;case"selectionchange":if(kx)break;case"keydown":case"keyup":cf(B,l,R)}var me;if(Uc)e:{switch(e){case"compositionstart":var be="onCompositionStart";break e;case"compositionend":be="onCompositionEnd";break e;case"compositionupdate":be="onCompositionUpdate";break e}be=void 0}else Va?Zu(e,l)&&(be="onCompositionEnd"):e==="keydown"&&l.keyCode===229&&(be="onCompositionStart");be&&(Qu&&l.locale!=="ko"&&(Va||be!=="onCompositionStart"?be==="onCompositionEnd"&&Va&&(me=Bu()):(Ll=R,Mc="value"in Ll?Ll.value:Ll.textContent,Va=!0)),F=Xi(C,be),0<F.length&&(be=new Gu(be,e,null,l,R),B.push({event:be,listeners:F}),me?be.data=me:(me=Ku(l),me!==null&&(be.data=me)))),(me=Nx?Sx(e,l):zx(e,l))&&(be=Xi(C,"onBeforeInput"),0<be.length&&(F=new Gu("onBeforeInput","beforeinput",null,l,R),B.push({event:F,listeners:be}),F.data=me)),pg(B,e,C,l,R)}Om(B,t)})}function xs(e,t,l){return{instance:e,listener:t,currentTarget:l}}function Xi(e,t){for(var l=t+"Capture",a=[];e!==null;){var s=e,r=s.stateNode;if(s=s.tag,s!==5&&s!==26&&s!==27||r===null||(s=Hn(e,l),s!=null&&a.unshift(xs(e,s,r)),s=Hn(e,t),s!=null&&a.push(xs(e,s,r))),e.tag===3)return a;e=e.return}return[]}function vg(e){if(e===null)return null;do e=e.return;while(e&&e.tag!==5&&e.tag!==27);return e||null}function Rm(e,t,l,a,s){for(var r=t._reactName,m=[];l!==null&&l!==a;){var b=l,j=b.alternate,C=b.stateNode;if(b=b.tag,j!==null&&j===a)break;b!==5&&b!==26&&b!==27||C===null||(j=C,s?(C=Hn(l,r),C!=null&&m.unshift(xs(l,C,j))):s||(C=Hn(l,r),C!=null&&m.push(xs(l,C,j)))),l=l.return}m.length!==0&&e.push({event:t,listeners:m})}var yg=/\r\n?/g,jg=/\u0000|\uFFFD/g;function Um(e){return(typeof e=="string"?e:""+e).replace(yg,` +`).replace(jg,"")}function Hm(e,t){return t=Um(t),Um(e)===t}function De(e,t,l,a,s,r){switch(l){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||$a(e,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&$a(e,""+a);break;case"className":Js(e,"class",a);break;case"tabIndex":Js(e,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Js(e,l,a);break;case"style":Uu(e,a,r);break;case"data":if(t!=="object"){Js(e,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||l!=="href")){e.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){e.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof r=="function"&&(l==="formAction"?(t!=="input"&&De(e,t,"name",s.name,s,null),De(e,t,"formEncType",s.formEncType,s,null),De(e,t,"formMethod",s.formMethod,s,null),De(e,t,"formTarget",s.formTarget,s,null)):(De(e,t,"encType",s.encType,s,null),De(e,t,"method",s.method,s,null),De(e,t,"target",s.target,s,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"onClick":a!=null&&(e.onclick=gl);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"multiple":e.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":e.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){e.removeAttribute("xlink:href");break}l=Fs(""+a),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""+a):e.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""):e.removeAttribute(l);break;case"capture":case"download":a===!0?e.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,a):e.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?e.setAttribute(l,a):e.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?e.removeAttribute(l):e.setAttribute(l,a);break;case"popover":xe("beforetoggle",e),xe("toggle",e),Ks(e,"popover",a);break;case"xlinkActuate":xl(e,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":xl(e,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":xl(e,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":xl(e,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":xl(e,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":xl(e,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":xl(e,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":xl(e,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":xl(e,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Ks(e,"is",a);break;case"innerText":case"textContent":break;default:(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")&&(l=Jp.get(l)||l,Ks(e,l,a))}}function mo(e,t,l,a,s,r){switch(l){case"style":Uu(e,a,r);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"children":typeof a=="string"?$a(e,a):(typeof a=="number"||typeof a=="bigint")&&$a(e,""+a);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"onClick":a!=null&&(e.onclick=gl);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"innerHTML":case"ref":break;case"innerText":case"textContent":break;default:if(!Tu.hasOwnProperty(l))e:{if(l[0]==="o"&&l[1]==="n"&&(s=l.endsWith("Capture"),t=l.slice(2,s?l.length-7:void 0),r=e[jt]||null,r=r!=null?r[l]:null,typeof r=="function"&&e.removeEventListener(t,r,s),typeof a=="function")){typeof r!="function"&&r!==null&&(l in e?e[l]=null:e.hasAttribute(l)&&e.removeAttribute(l)),e.addEventListener(t,a,s);break e}l in e?e[l]=a:a===!0?e.setAttribute(l,""):Ks(e,l,a)}}}function ut(e,t,l){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"img":xe("error",e),xe("load",e);var a=!1,s=!1,r;for(r in l)if(l.hasOwnProperty(r)){var m=l[r];if(m!=null)switch(r){case"src":a=!0;break;case"srcSet":s=!0;break;case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,r,m,l,null)}}s&&De(e,t,"srcSet",l.srcSet,l,null),a&&De(e,t,"src",l.src,l,null);return;case"input":xe("invalid",e);var b=r=m=s=null,j=null,C=null;for(a in l)if(l.hasOwnProperty(a)){var R=l[a];if(R!=null)switch(a){case"name":s=R;break;case"type":m=R;break;case"checked":j=R;break;case"defaultChecked":C=R;break;case"value":r=R;break;case"defaultValue":b=R;break;case"children":case"dangerouslySetInnerHTML":if(R!=null)throw Error(u(137,t));break;default:De(e,t,a,R,l,null)}}ku(e,r,b,j,C,m,s,!1);return;case"select":xe("invalid",e),a=m=r=null;for(s in l)if(l.hasOwnProperty(s)&&(b=l[s],b!=null))switch(s){case"value":r=b;break;case"defaultValue":m=b;break;case"multiple":a=b;default:De(e,t,s,b,l,null)}t=r,l=m,e.multiple=!!a,t!=null?Ga(e,!!a,t,!1):l!=null&&Ga(e,!!a,l,!0);return;case"textarea":xe("invalid",e),r=s=a=null;for(m in l)if(l.hasOwnProperty(m)&&(b=l[m],b!=null))switch(m){case"value":a=b;break;case"defaultValue":s=b;break;case"children":r=b;break;case"dangerouslySetInnerHTML":if(b!=null)throw Error(u(91));break;default:De(e,t,m,b,l,null)}Du(e,a,s,r);return;case"option":for(j in l)if(l.hasOwnProperty(j)&&(a=l[j],a!=null))switch(j){case"selected":e.selected=a&&typeof a!="function"&&typeof a!="symbol";break;default:De(e,t,j,a,l,null)}return;case"dialog":xe("beforetoggle",e),xe("toggle",e),xe("cancel",e),xe("close",e);break;case"iframe":case"object":xe("load",e);break;case"video":case"audio":for(a=0;a<ps.length;a++)xe(ps[a],e);break;case"image":xe("error",e),xe("load",e);break;case"details":xe("toggle",e);break;case"embed":case"source":case"link":xe("error",e),xe("load",e);case"area":case"base":case"br":case"col":case"hr":case"keygen":case"meta":case"param":case"track":case"wbr":case"menuitem":for(C in l)if(l.hasOwnProperty(C)&&(a=l[C],a!=null))switch(C){case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,C,a,l,null)}return;default:if(wc(t)){for(R in l)l.hasOwnProperty(R)&&(a=l[R],a!==void 0&&mo(e,t,R,a,l,void 0));return}}for(b in l)l.hasOwnProperty(b)&&(a=l[b],a!=null&&De(e,t,b,a,l,null))}function Ng(e,t,l,a){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"input":var s=null,r=null,m=null,b=null,j=null,C=null,R=null;for(O in l){var B=l[O];if(l.hasOwnProperty(O)&&B!=null)switch(O){case"checked":break;case"value":break;case"defaultValue":j=B;default:a.hasOwnProperty(O)||De(e,t,O,null,a,B)}}for(var M in a){var O=a[M];if(B=l[M],a.hasOwnProperty(M)&&(O!=null||B!=null))switch(M){case"type":r=O;break;case"name":s=O;break;case"checked":C=O;break;case"defaultChecked":R=O;break;case"value":m=O;break;case"defaultValue":b=O;break;case"children":case"dangerouslySetInnerHTML":if(O!=null)throw Error(u(137,t));break;default:O!==B&&De(e,t,M,O,a,B)}}zc(e,m,b,j,C,R,r,s);return;case"select":O=m=b=M=null;for(r in l)if(j=l[r],l.hasOwnProperty(r)&&j!=null)switch(r){case"value":break;case"multiple":O=j;default:a.hasOwnProperty(r)||De(e,t,r,null,a,j)}for(s in a)if(r=a[s],j=l[s],a.hasOwnProperty(s)&&(r!=null||j!=null))switch(s){case"value":M=r;break;case"defaultValue":b=r;break;case"multiple":m=r;default:r!==j&&De(e,t,s,r,a,j)}t=b,l=m,a=O,M!=null?Ga(e,!!l,M,!1):!!a!=!!l&&(t!=null?Ga(e,!!l,t,!0):Ga(e,!!l,l?[]:"",!1));return;case"textarea":O=M=null;for(b in l)if(s=l[b],l.hasOwnProperty(b)&&s!=null&&!a.hasOwnProperty(b))switch(b){case"value":break;case"children":break;default:De(e,t,b,null,a,s)}for(m in a)if(s=a[m],r=l[m],a.hasOwnProperty(m)&&(s!=null||r!=null))switch(m){case"value":M=s;break;case"defaultValue":O=s;break;case"children":break;case"dangerouslySetInnerHTML":if(s!=null)throw Error(u(91));break;default:s!==r&&De(e,t,m,s,a,r)}Ou(e,M,O);return;case"option":for(var W in l)if(M=l[W],l.hasOwnProperty(W)&&M!=null&&!a.hasOwnProperty(W))switch(W){case"selected":e.selected=!1;break;default:De(e,t,W,null,a,M)}for(j in a)if(M=a[j],O=l[j],a.hasOwnProperty(j)&&M!==O&&(M!=null||O!=null))switch(j){case"selected":e.selected=M&&typeof M!="function"&&typeof M!="symbol";break;default:De(e,t,j,M,a,O)}return;case"img":case"link":case"area":case"base":case"br":case"col":case"embed":case"hr":case"keygen":case"meta":case"param":case"source":case"track":case"wbr":case"menuitem":for(var ie in l)M=l[ie],l.hasOwnProperty(ie)&&M!=null&&!a.hasOwnProperty(ie)&&De(e,t,ie,null,a,M);for(C in a)if(M=a[C],O=l[C],a.hasOwnProperty(C)&&M!==O&&(M!=null||O!=null))switch(C){case"children":case"dangerouslySetInnerHTML":if(M!=null)throw Error(u(137,t));break;default:De(e,t,C,M,a,O)}return;default:if(wc(t)){for(var Re in l)M=l[Re],l.hasOwnProperty(Re)&&M!==void 0&&!a.hasOwnProperty(Re)&&mo(e,t,Re,void 0,a,M);for(R in a)M=a[R],O=l[R],!a.hasOwnProperty(R)||M===O||M===void 0&&O===void 0||mo(e,t,R,M,a,O);return}}for(var T in l)M=l[T],l.hasOwnProperty(T)&&M!=null&&!a.hasOwnProperty(T)&&De(e,t,T,null,a,M);for(B in a)M=a[B],O=l[B],!a.hasOwnProperty(B)||M===O||M==null&&O==null||De(e,t,B,M,a,O)}function Lm(e){switch(e){case"css":case"script":case"font":case"img":case"image":case"input":case"link":return!0;default:return!1}}function Sg(){if(typeof performance.getEntriesByType=="function"){for(var e=0,t=0,l=performance.getEntriesByType("resource"),a=0;a<l.length;a++){var s=l[a],r=s.transferSize,m=s.initiatorType,b=s.duration;if(r&&b&&Lm(m)){for(m=0,b=s.responseEnd,a+=1;a<l.length;a++){var j=l[a],C=j.startTime;if(C>b)break;var R=j.transferSize,B=j.initiatorType;R&&Lm(B)&&(j=j.responseEnd,m+=R*(j<b?1:(b-C)/(j-C)))}if(--a,t+=8*(r+m)/(s.duration/1e3),e++,10<e)break}}if(0<e)return t/e/1e6}return navigator.connection&&(e=navigator.connection.downlink,typeof e=="number")?e:5}var ho=null,po=null;function Vi(e){return e.nodeType===9?e:e.ownerDocument}function Bm(e){switch(e){case"http://www.w3.org/2000/svg":return 1;case"http://www.w3.org/1998/Math/MathML":return 2;default:return 0}}function qm(e,t){if(e===0)switch(t){case"svg":return 1;case"math":return 2;default:return 0}return e===1&&t==="foreignObject"?0:e}function xo(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.children=="bigint"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var go=null;function zg(){var e=window.event;return e&&e.type==="popstate"?e===go?!1:(go=e,!0):(go=null,!1)}var Ym=typeof setTimeout=="function"?setTimeout:void 0,Eg=typeof clearTimeout=="function"?clearTimeout:void 0,Gm=typeof Promise=="function"?Promise:void 0,wg=typeof queueMicrotask=="function"?queueMicrotask:typeof Gm<"u"?function(e){return Gm.resolve(null).then(e).catch(Tg)}:Ym;function Tg(e){setTimeout(function(){throw e})}function ta(e){return e==="head"}function $m(e,t){var l=t,a=0;do{var s=l.nextSibling;if(e.removeChild(l),s&&s.nodeType===8)if(l=s.data,l==="/$"||l==="/&"){if(a===0){e.removeChild(s),jn(t);return}a--}else if(l==="$"||l==="$?"||l==="$~"||l==="$!"||l==="&")a++;else if(l==="html")gs(e.ownerDocument.documentElement);else if(l==="head"){l=e.ownerDocument.head,gs(l);for(var r=l.firstChild;r;){var m=r.nextSibling,b=r.nodeName;r[Rn]||b==="SCRIPT"||b==="STYLE"||b==="LINK"&&r.rel.toLowerCase()==="stylesheet"||l.removeChild(r),r=m}}else l==="body"&&gs(e.ownerDocument.body);l=s}while(l);jn(t)}function Qm(e,t){var l=e;e=0;do{var a=l.nextSibling;if(l.nodeType===1?t?(l._stashedDisplay=l.style.display,l.style.display="none"):(l.style.display=l._stashedDisplay||"",l.getAttribute("style")===""&&l.removeAttribute("style")):l.nodeType===3&&(t?(l._stashedText=l.nodeValue,l.nodeValue=""):l.nodeValue=l._stashedText||""),a&&a.nodeType===8)if(l=a.data,l==="/$"){if(e===0)break;e--}else l!=="$"&&l!=="$?"&&l!=="$~"&&l!=="$!"||e++;l=a}while(l)}function bo(e){var t=e.firstChild;for(t&&t.nodeType===10&&(t=t.nextSibling);t;){var l=t;switch(t=t.nextSibling,l.nodeName){case"HTML":case"HEAD":case"BODY":bo(l),Nc(l);continue;case"SCRIPT":case"STYLE":continue;case"LINK":if(l.rel.toLowerCase()==="stylesheet")continue}e.removeChild(l)}}function Ag(e,t,l,a){for(;e.nodeType===1;){var s=l;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!a&&(e.nodeName!=="INPUT"||e.type!=="hidden"))break}else if(a){if(!e[Rn])switch(t){case"meta":if(!e.hasAttribute("itemprop"))break;return e;case"link":if(r=e.getAttribute("rel"),r==="stylesheet"&&e.hasAttribute("data-precedence"))break;if(r!==s.rel||e.getAttribute("href")!==(s.href==null||s.href===""?null:s.href)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin)||e.getAttribute("title")!==(s.title==null?null:s.title))break;return e;case"style":if(e.hasAttribute("data-precedence"))break;return e;case"script":if(r=e.getAttribute("src"),(r!==(s.src==null?null:s.src)||e.getAttribute("type")!==(s.type==null?null:s.type)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin))&&r&&e.hasAttribute("async")&&!e.hasAttribute("itemprop"))break;return e;default:return e}}else if(t==="input"&&e.type==="hidden"){var r=s.name==null?null:""+s.name;if(s.type==="hidden"&&e.getAttribute("name")===r)return e}else return e;if(e=Vt(e.nextSibling),e===null)break}return null}function _g(e,t,l){if(t==="")return null;for(;e.nodeType!==3;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!l||(e=Vt(e.nextSibling),e===null))return null;return e}function Xm(e,t){for(;e.nodeType!==8;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!t||(e=Vt(e.nextSibling),e===null))return null;return e}function vo(e){return e.data==="$?"||e.data==="$~"}function yo(e){return e.data==="$!"||e.data==="$?"&&e.ownerDocument.readyState!=="loading"}function Cg(e,t){var l=e.ownerDocument;if(e.data==="$~")e._reactRetry=t;else if(e.data!=="$?"||l.readyState!=="loading")t();else{var a=function(){t(),l.removeEventListener("DOMContentLoaded",a)};l.addEventListener("DOMContentLoaded",a),e._reactRetry=a}}function Vt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?"||t==="$~"||t==="&"||t==="F!"||t==="F")break;if(t==="/$"||t==="/&")return null}}return e}var jo=null;function Vm(e){e=e.nextSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="/$"||l==="/&"){if(t===0)return Vt(e.nextSibling);t--}else l!=="$"&&l!=="$!"&&l!=="$?"&&l!=="$~"&&l!=="&"||t++}e=e.nextSibling}return null}function Zm(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="$"||l==="$!"||l==="$?"||l==="$~"||l==="&"){if(t===0)return e;t--}else l!=="/$"&&l!=="/&"||t++}e=e.previousSibling}return null}function Km(e,t,l){switch(t=Vi(l),e){case"html":if(e=t.documentElement,!e)throw Error(u(452));return e;case"head":if(e=t.head,!e)throw Error(u(453));return e;case"body":if(e=t.body,!e)throw Error(u(454));return e;default:throw Error(u(451))}}function gs(e){for(var t=e.attributes;t.length;)e.removeAttributeNode(t[0]);Nc(e)}var Zt=new Map,Jm=new Set;function Zi(e){return typeof e.getRootNode=="function"?e.getRootNode():e.nodeType===9?e:e.ownerDocument}var Ol=Y.d;Y.d={f:Mg,r:kg,D:Og,C:Dg,L:Rg,m:Ug,X:Lg,S:Hg,M:Bg};function Mg(){var e=Ol.f(),t=Li();return e||t}function kg(e){var t=Ba(e);t!==null&&t.tag===5&&t.type==="form"?dd(t):Ol.r(e)}var bn=typeof document>"u"?null:document;function Wm(e,t,l){var a=bn;if(a&&typeof t=="string"&&t){var s=Bt(t);s='link[rel="'+e+'"][href="'+s+'"]',typeof l=="string"&&(s+='[crossorigin="'+l+'"]'),Jm.has(s)||(Jm.add(s),e={rel:e,crossOrigin:l,href:t},a.querySelector(s)===null&&(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Og(e){Ol.D(e),Wm("dns-prefetch",e,null)}function Dg(e,t){Ol.C(e,t),Wm("preconnect",e,t)}function Rg(e,t,l){Ol.L(e,t,l);var a=bn;if(a&&e&&t){var s='link[rel="preload"][as="'+Bt(t)+'"]';t==="image"&&l&&l.imageSrcSet?(s+='[imagesrcset="'+Bt(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(s+='[imagesizes="'+Bt(l.imageSizes)+'"]')):s+='[href="'+Bt(e)+'"]';var r=s;switch(t){case"style":r=vn(e);break;case"script":r=yn(e)}Zt.has(r)||(e=y({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),Zt.set(r,e),a.querySelector(s)!==null||t==="style"&&a.querySelector(bs(r))||t==="script"&&a.querySelector(vs(r))||(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Ug(e,t){Ol.m(e,t);var l=bn;if(l&&e){var a=t&&typeof t.as=="string"?t.as:"script",s='link[rel="modulepreload"][as="'+Bt(a)+'"][href="'+Bt(e)+'"]',r=s;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":r=yn(e)}if(!Zt.has(r)&&(e=y({rel:"modulepreload",href:e},t),Zt.set(r,e),l.querySelector(s)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(vs(r)))return}a=l.createElement("link"),ut(a,"link",e),nt(a),l.head.appendChild(a)}}}function Hg(e,t,l){Ol.S(e,t,l);var a=bn;if(a&&e){var s=qa(a).hoistableStyles,r=vn(e);t=t||"default";var m=s.get(r);if(!m){var b={loading:0,preload:null};if(m=a.querySelector(bs(r)))b.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":t},l),(l=Zt.get(r))&&No(e,l);var j=m=a.createElement("link");nt(j),ut(j,"link",e),j._p=new Promise(function(C,R){j.onload=C,j.onerror=R}),j.addEventListener("load",function(){b.loading|=1}),j.addEventListener("error",function(){b.loading|=2}),b.loading|=4,Ki(m,t,a)}m={type:"stylesheet",instance:m,count:1,state:b},s.set(r,m)}}}function Lg(e,t){Ol.X(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Bg(e,t){Ol.M(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0,type:"module"},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Fm(e,t,l,a){var s=(s=fe.current)?Zi(s):null;if(!s)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=vn(l.href),l=qa(s).hoistableStyles,a=l.get(t),a||(a={type:"style",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=vn(l.href);var r=qa(s).hoistableStyles,m=r.get(e);if(m||(s=s.ownerDocument||s,m={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},r.set(e,m),(r=s.querySelector(bs(e)))&&!r._p&&(m.instance=r,m.state.loading=5),Zt.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},Zt.set(e,l),r||qg(s,e,l,m.state))),t&&a===null)throw Error(u(528,""));return m}if(t&&a!==null)throw Error(u(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=yn(l),l=qa(s).hoistableScripts,a=l.get(t),a||(a={type:"script",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function vn(e){return'href="'+Bt(e)+'"'}function bs(e){return'link[rel="stylesheet"]['+e+"]"}function Im(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function qg(e,t,l,a){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=e.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),ut(t,"link",l),nt(t),e.head.appendChild(t))}function yn(e){return'[src="'+Bt(e)+'"]'}function vs(e){return"script[async]"+e}function Pm(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var a=e.querySelector('style[data-href~="'+Bt(l.href)+'"]');if(a)return t.instance=a,nt(a),a;var s=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return a=(e.ownerDocument||e).createElement("style"),nt(a),ut(a,"style",s),Ki(a,l.precedence,e),t.instance=a;case"stylesheet":s=vn(l.href);var r=e.querySelector(bs(s));if(r)return t.state.loading|=4,t.instance=r,nt(r),r;a=Im(l),(s=Zt.get(s))&&No(a,s),r=(e.ownerDocument||e).createElement("link"),nt(r);var m=r;return m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),t.state.loading|=4,Ki(r,l.precedence,e),t.instance=r;case"script":return r=yn(l.src),(s=e.querySelector(vs(r)))?(t.instance=s,nt(s),s):(a=l,(s=Zt.get(r))&&(a=y({},l),So(a,s)),e=e.ownerDocument||e,s=e.createElement("script"),nt(s),ut(s,"link",a),e.head.appendChild(s),t.instance=s);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,Ki(a,l.precedence,e));return t.instance}function Ki(e,t,l){for(var a=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),s=a.length?a[a.length-1]:null,r=s,m=0;m<a.length;m++){var b=a[m];if(b.dataset.precedence===t)r=b;else if(r!==s)break}r?r.parentNode.insertBefore(e,r.nextSibling):(t=l.nodeType===9?l.head:l,t.insertBefore(e,t.firstChild))}function No(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.title==null&&(e.title=t.title)}function So(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.integrity==null&&(e.integrity=t.integrity)}var Ji=null;function eh(e,t,l){if(Ji===null){var a=new Map,s=Ji=new Map;s.set(l,a)}else s=Ji,a=s.get(l),a||(a=new Map,s.set(l,a));if(a.has(e))return a;for(a.set(e,null),l=l.getElementsByTagName(e),s=0;s<l.length;s++){var r=l[s];if(!(r[Rn]||r[it]||e==="link"&&r.getAttribute("rel")==="stylesheet")&&r.namespaceURI!=="http://www.w3.org/2000/svg"){var m=r.getAttribute(t)||"";m=e+m;var b=a.get(m);b?b.push(r):a.set(m,[r])}}return a}function th(e,t,l){e=e.ownerDocument||e,e.head.insertBefore(l,t==="title"?e.querySelector("head > title"):null)}function Yg(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function lh(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function Gg(e,t,l,a){if(l.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var s=vn(a.href),r=t.querySelector(bs(s));if(r){t=r._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Wi.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=r,nt(r);return}r=t.ownerDocument||t,a=Im(a),(s=Zt.get(s))&&No(a,s),r=r.createElement("link"),nt(r);var m=r;m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),l.instance=r}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=Wi.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var zo=0;function $g(e,t){return e.stylesheets&&e.count===0&&Ii(e,e.stylesheets),0<e.count||0<e.imgCount?function(l){var a=setTimeout(function(){if(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend){var r=e.unsuspend;e.unsuspend=null,r()}},6e4+t);0<e.imgBytes&&zo===0&&(zo=62500*Sg());var s=setTimeout(function(){if(e.waitingForImages=!1,e.count===0&&(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend)){var r=e.unsuspend;e.unsuspend=null,r()}},(e.imgBytes>zo?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(a),clearTimeout(s)}}:null}function Wi(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ii(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Fi=null;function Ii(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Fi=new Map,t.forEach(Qg,e),Fi=null,Wi.call(e))}function Qg(e,t){if(!(t.state.loading&4)){var l=Fi.get(e);if(l)var a=l.get(null);else{l=new Map,Fi.set(e,l);for(var s=e.querySelectorAll("link[data-precedence],style[data-precedence]"),r=0;r<s.length;r++){var m=s[r];(m.nodeName==="LINK"||m.getAttribute("media")!=="not all")&&(l.set(m.dataset.precedence,m),a=m)}a&&l.set(null,a)}s=t.instance,m=s.getAttribute("data-precedence"),r=l.get(m)||a,r===a&&l.set(null,s),l.set(m,s),this.count++,a=Wi.bind(this),s.addEventListener("load",a),s.addEventListener("error",a),r?r.parentNode.insertBefore(s,r.nextSibling):(e=e.nodeType===9?e.head:e,e.insertBefore(s,e.firstChild)),t.state.loading|=4}}var ys={$$typeof:Z,Provider:null,Consumer:null,_currentValue:te,_currentValue2:te,_threadCount:0};function Xg(e,t,l,a,s,r,m,b,j){this.tag=1,this.containerInfo=e,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=bc(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=bc(0),this.hiddenUpdates=bc(null),this.identifierPrefix=a,this.onUncaughtError=s,this.onCaughtError=r,this.onRecoverableError=m,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=j,this.incompleteTransitions=new Map}function ah(e,t,l,a,s,r,m,b,j,C,R,B){return e=new Xg(e,t,l,m,j,C,R,B,b),t=1,r===!0&&(t|=24),r=Mt(3,null,null,t),e.current=r,r.stateNode=e,t=lr(),t.refCount++,e.pooledCache=t,t.refCount++,r.memoizedState={element:a,isDehydrated:l,cache:t},ir(r),e}function nh(e){return e?(e=Wa,e):Wa}function sh(e,t,l,a,s,r){s=nh(s),a.context===null?a.context=s:a.pendingContext=s,a=Ql(t),a.payload={element:l},r=r===void 0?null:r,r!==null&&(a.callback=r),l=Xl(e,a,t),l!==null&&(Tt(l,e,t),In(l,e,t))}function ih(e,t){if(e=e.memoizedState,e!==null&&e.dehydrated!==null){var l=e.retryLane;e.retryLane=l!==0&&l<t?l:t}}function Eo(e,t){ih(e,t),(e=e.alternate)&&ih(e,t)}function ch(e){if(e.tag===13||e.tag===31){var t=ba(e,67108864);t!==null&&Tt(t,e,67108864),Eo(e,67108864)}}function rh(e){if(e.tag===13||e.tag===31){var t=Ut();t=vc(t);var l=ba(e,t);l!==null&&Tt(l,e,t),Eo(e,t)}}var Pi=!0;function Vg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=2,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function Zg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=8,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function wo(e,t,l,a){if(Pi){var s=To(a);if(s===null)fo(e,t,a,ec,l),uh(e,a);else if(Jg(s,e,t,l,a))a.stopPropagation();else if(uh(e,a),t&4&&-1<Kg.indexOf(e)){for(;s!==null;){var r=Ba(s);if(r!==null)switch(r.tag){case 3:if(r=r.stateNode,r.current.memoizedState.isDehydrated){var m=sl(r.pendingLanes);if(m!==0){var b=r;for(b.pendingLanes|=2,b.entangledLanes|=2;m;){var j=1<<31-xt(m);b.entanglements[1]|=j,m&=~j}ol(r),(we&6)===0&&(Ui=dt()+500,hs(0))}}break;case 31:case 13:b=ba(r,2),b!==null&&Tt(b,r,2),Li(),Eo(r,2)}if(r=To(a),r===null&&fo(e,t,a,ec,l),r===s)break;s=r}s!==null&&a.stopPropagation()}else fo(e,t,a,null,l)}}function To(e){return e=Ac(e),Ao(e)}var ec=null;function Ao(e){if(ec=null,e=La(e),e!==null){var t=d(e);if(t===null)e=null;else{var l=t.tag;if(l===13){if(e=h(t),e!==null)return e;e=null}else if(l===31){if(e=p(t),e!==null)return e;e=null}else if(l===3){if(t.stateNode.current.memoizedState.isDehydrated)return t.tag===3?t.stateNode.containerInfo:null;e=null}else t!==e&&(e=null)}}return ec=e,null}function oh(e){switch(e){case"beforetoggle":case"cancel":case"click":case"close":case"contextmenu":case"copy":case"cut":case"auxclick":case"dblclick":case"dragend":case"dragstart":case"drop":case"focusin":case"focusout":case"input":case"invalid":case"keydown":case"keypress":case"keyup":case"mousedown":case"mouseup":case"paste":case"pause":case"play":case"pointercancel":case"pointerdown":case"pointerup":case"ratechange":case"reset":case"resize":case"seeked":case"submit":case"toggle":case"touchcancel":case"touchend":case"touchstart":case"volumechange":case"change":case"selectionchange":case"textInput":case"compositionstart":case"compositionend":case"compositionupdate":case"beforeblur":case"afterblur":case"beforeinput":case"blur":case"fullscreenchange":case"focus":case"hashchange":case"popstate":case"select":case"selectstart":return 2;case"drag":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"mousemove":case"mouseout":case"mouseover":case"pointermove":case"pointerout":case"pointerover":case"scroll":case"touchmove":case"wheel":case"mouseenter":case"mouseleave":case"pointerenter":case"pointerleave":return 8;case"message":switch($s()){case Qs:return 2;case Cn:return 8;case Da:case Mn:return 32;case pl:return 268435456;default:return 32}default:return 32}}var _o=!1,la=null,aa=null,na=null,js=new Map,Ns=new Map,sa=[],Kg="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset".split(" ");function uh(e,t){switch(e){case"focusin":case"focusout":la=null;break;case"dragenter":case"dragleave":aa=null;break;case"mouseover":case"mouseout":na=null;break;case"pointerover":case"pointerout":js.delete(t.pointerId);break;case"gotpointercapture":case"lostpointercapture":Ns.delete(t.pointerId)}}function Ss(e,t,l,a,s,r){return e===null||e.nativeEvent!==r?(e={blockedOn:t,domEventName:l,eventSystemFlags:a,nativeEvent:r,targetContainers:[s]},t!==null&&(t=Ba(t),t!==null&&ch(t)),e):(e.eventSystemFlags|=a,t=e.targetContainers,s!==null&&t.indexOf(s)===-1&&t.push(s),e)}function Jg(e,t,l,a,s){switch(t){case"focusin":return la=Ss(la,e,t,l,a,s),!0;case"dragenter":return aa=Ss(aa,e,t,l,a,s),!0;case"mouseover":return na=Ss(na,e,t,l,a,s),!0;case"pointerover":var r=s.pointerId;return js.set(r,Ss(js.get(r)||null,e,t,l,a,s)),!0;case"gotpointercapture":return r=s.pointerId,Ns.set(r,Ss(Ns.get(r)||null,e,t,l,a,s)),!0}return!1}function fh(e){var t=La(e.target);if(t!==null){var l=d(t);if(l!==null){if(t=l.tag,t===13){if(t=h(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===31){if(t=p(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===3&&l.stateNode.current.memoizedState.isDehydrated){e.blockedOn=l.tag===3?l.stateNode.containerInfo:null;return}}}e.blockedOn=null}function tc(e){if(e.blockedOn!==null)return!1;for(var t=e.targetContainers;0<t.length;){var l=To(e.nativeEvent);if(l===null){l=e.nativeEvent;var a=new l.constructor(l.type,l);Tc=a,l.target.dispatchEvent(a),Tc=null}else return t=Ba(l),t!==null&&ch(t),e.blockedOn=l,!1;t.shift()}return!0}function dh(e,t,l){tc(e)&&l.delete(t)}function Wg(){_o=!1,la!==null&&tc(la)&&(la=null),aa!==null&&tc(aa)&&(aa=null),na!==null&&tc(na)&&(na=null),js.forEach(dh),Ns.forEach(dh)}function lc(e,t){e.blockedOn===t&&(e.blockedOn=null,_o||(_o=!0,i.unstable_scheduleCallback(i.unstable_NormalPriority,Wg)))}var ac=null;function mh(e){ac!==e&&(ac=e,i.unstable_scheduleCallback(i.unstable_NormalPriority,function(){ac===e&&(ac=null);for(var t=0;t<e.length;t+=3){var l=e[t],a=e[t+1],s=e[t+2];if(typeof a!="function"){if(Ao(a||l)===null)continue;break}var r=Ba(l);r!==null&&(e.splice(t,3),t-=3,wr(r,{pending:!0,data:s,method:l.method,action:a},a,s))}}))}function jn(e){function t(j){return lc(j,e)}la!==null&&lc(la,e),aa!==null&&lc(aa,e),na!==null&&lc(na,e),js.forEach(t),Ns.forEach(t);for(var l=0;l<sa.length;l++){var a=sa[l];a.blockedOn===e&&(a.blockedOn=null)}for(;0<sa.length&&(l=sa[0],l.blockedOn===null);)fh(l),l.blockedOn===null&&sa.shift();if(l=(e.ownerDocument||e).$$reactFormReplay,l!=null)for(a=0;a<l.length;a+=3){var s=l[a],r=l[a+1],m=s[jt]||null;if(typeof r=="function")m||mh(l);else if(m){var b=null;if(r&&r.hasAttribute("formAction")){if(s=r,m=r[jt]||null)b=m.formAction;else if(Ao(s)!==null)continue}else b=m.action;typeof b=="function"?l[a+1]=b:(l.splice(a,3),a-=3),mh(l)}}}function hh(){function e(r){r.canIntercept&&r.info==="react-transition"&&r.intercept({handler:function(){return new Promise(function(m){return s=m})},focusReset:"manual",scroll:"manual"})}function t(){s!==null&&(s(),s=null),a||setTimeout(l,20)}function l(){if(!a&&!navigation.transition){var r=navigation.currentEntry;r&&r.url!=null&&navigation.navigate(r.url,{state:r.getState(),info:"react-transition",history:"replace"})}}if(typeof navigation=="object"){var a=!1,s=null;return navigation.addEventListener("navigate",e),navigation.addEventListener("navigatesuccess",t),navigation.addEventListener("navigateerror",t),setTimeout(l,100),function(){a=!0,navigation.removeEventListener("navigate",e),navigation.removeEventListener("navigatesuccess",t),navigation.removeEventListener("navigateerror",t),s!==null&&(s(),s=null)}}}function Co(e){this._internalRoot=e}nc.prototype.render=Co.prototype.render=function(e){var t=this._internalRoot;if(t===null)throw Error(u(409));var l=t.current,a=Ut();sh(l,a,e,t,null,null)},nc.prototype.unmount=Co.prototype.unmount=function(){var e=this._internalRoot;if(e!==null){this._internalRoot=null;var t=e.containerInfo;sh(e.current,2,null,e,null,null),Li(),t[Ha]=null}};function nc(e){this._internalRoot=e}nc.prototype.unstable_scheduleHydration=function(e){if(e){var t=Su();e={blockedOn:null,target:e,priority:t};for(var l=0;l<sa.length&&t!==0&&t<sa[l].priority;l++);sa.splice(l,0,e),l===0&&fh(e)}};var ph=c.version;if(ph!=="19.2.8")throw Error(u(527,ph,"19.2.8"));Y.findDOMNode=function(e){var t=e._reactInternals;if(t===void 0)throw typeof e.render=="function"?Error(u(188)):(e=Object.keys(e).join(","),Error(u(268,e)));return e=x(t),e=e!==null?v(e):null,e=e===null?null:e.stateNode,e};var Fg={bundleType:0,version:"19.2.8",rendererPackageName:"react-dom",currentDispatcherRef:k,reconcilerVersion:"19.2.8"};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"){var sc=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!sc.isDisabled&&sc.supportsFiber)try{al=sc.inject(Fg),pt=sc}catch{}}return Es.createRoot=function(e,t){if(!f(e))throw Error(u(299));var l=!1,a="",s=Nd,r=Sd,m=zd;return t!=null&&(t.unstable_strictMode===!0&&(l=!0),t.identifierPrefix!==void 0&&(a=t.identifierPrefix),t.onUncaughtError!==void 0&&(s=t.onUncaughtError),t.onCaughtError!==void 0&&(r=t.onCaughtError),t.onRecoverableError!==void 0&&(m=t.onRecoverableError)),t=ah(e,1,!1,null,null,l,a,null,s,r,m,hh),e[Ha]=t.current,uo(e),new Co(t)},Es.hydrateRoot=function(e,t,l){if(!f(e))throw Error(u(299));var a=!1,s="",r=Nd,m=Sd,b=zd,j=null;return l!=null&&(l.unstable_strictMode===!0&&(a=!0),l.identifierPrefix!==void 0&&(s=l.identifierPrefix),l.onUncaughtError!==void 0&&(r=l.onUncaughtError),l.onCaughtError!==void 0&&(m=l.onCaughtError),l.onRecoverableError!==void 0&&(b=l.onRecoverableError),l.formState!==void 0&&(j=l.formState)),t=ah(e,1,!0,t,l??null,a,s,j,r,m,b,hh),t.context=nh(null),l=t.current,a=Ut(),a=vc(a),s=Ql(a),s.callback=null,Xl(l,s,a),l=a,t.current.lanes=l,Dn(t,l),ol(t),e[Ha]=t.current,uo(e),new nc(t)},Es.version="19.2.8",Es}var wh;function ub(){if(wh)return Oo.exports;wh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Oo.exports=ob(),Oo.exports}var Qo=ub();/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fb=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),db=i=>i.replace(/^([A-Z])|[\s-_]+(\w)/g,(c,o,u)=>u?u.toUpperCase():o.toLowerCase()),Th=i=>{const c=db(i);return c.charAt(0).toUpperCase()+c.slice(1)},fp=(...i)=>i.filter((c,o,u)=>!!c&&c.trim()!==""&&u.indexOf(c)===o).join(" ").trim(),mb=i=>{for(const c in i)if(c.startsWith("aria-")||c==="role"||c==="title")return!0};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var hb={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pb=q.forwardRef(({color:i="currentColor",size:c=24,strokeWidth:o=2,absoluteStrokeWidth:u,className:f="",children:d,iconNode:h,...p},g)=>q.createElement("svg",{ref:g,...hb,width:c,height:c,stroke:i,strokeWidth:u?Number(o)*24/Number(c):o,className:fp("lucide",f),...!d&&!mb(p)&&{"aria-hidden":"true"},...p},[...h.map(([x,v])=>q.createElement(x,v)),...Array.isArray(d)?d:[d]]));/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const He=(i,c)=>{const o=q.forwardRef(({className:u,...f},d)=>q.createElement(pb,{ref:d,iconNode:c,className:fp(`lucide-${fb(Th(i))}`,`lucide-${i}`,u),...f}));return o.displayName=Th(i),o};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const xb=[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]],Sn=He("arrow-up-right",xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gb=[["path",{d:"M2 20v-8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v8",key:"1k78r4"}],["path",{d:"M4 10V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4",key:"fb3tl2"}],["path",{d:"M12 4v6",key:"1dcgq2"}],["path",{d:"M2 18h20",key:"ajqnye"}]],bb=He("bed-double",gb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"m9 16 2 2 4-4",key:"19s6y9"}]],yb=He("calendar-check",vb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"M8 14h.01",key:"6423bh"}],["path",{d:"M12 14h.01",key:"1etili"}],["path",{d:"M16 14h.01",key:"1gbofw"}],["path",{d:"M8 18h.01",key:"lrp35t"}],["path",{d:"M12 18h.01",key:"mhygvu"}],["path",{d:"M16 18h.01",key:"kzsmim"}]],Nb=He("calendar-days",jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Sb=[["path",{d:"M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2",key:"5owen"}],["circle",{cx:"7",cy:"17",r:"2",key:"u2ysq9"}],["path",{d:"M9 17h6",key:"r8uit2"}],["circle",{cx:"17",cy:"17",r:"2",key:"axvx0g"}]],zb=He("car",Sb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Eb=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],dp=He("check",Eb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const wb=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],fc=He("chevron-down",wb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Tb=[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]],oa=He("chevron-left",Tb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ab=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],ua=He("chevron-right",Ab);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _b=[["path",{d:"M12 6v6l4 2",key:"mmk7yg"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],Cb=He("clock",_b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mb=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],kb=He("copy",Mb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ob=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],Db=He("external-link",Ob);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Rb=[["rect",{width:"20",height:"20",x:"2",y:"2",rx:"5",ry:"5",key:"2e1cvw"}],["path",{d:"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z",key:"9exkf1"}],["line",{x1:"17.5",x2:"17.51",y1:"6.5",y2:"6.5",key:"r4j83e"}]],Ub=He("instagram",Rb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hb=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],mp=He("mail",Hb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Lb=[["path",{d:"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",key:"1r0f0z"}],["circle",{cx:"12",cy:"10",r:"3",key:"ilqhr7"}]],Ls=He("map-pin",Lb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Bb=[["path",{d:"M4 5h16",key:"1tepv9"}],["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 19h16",key:"1djgab"}]],hp=He("menu",Bb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qb=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],Io=He("message-circle",qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yb=[["path",{d:"M5 12h14",key:"1ays0h"}]],Gb=He("minus",Yb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $b=[["polygon",{points:"3 11 22 2 13 21 11 13 3 11",key:"1ltx0t"}]],Ho=He("navigation",$b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qb=[["path",{d:"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",key:"9njp5v"}]],_t=He("phone",Qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Xb=[["path",{d:"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z",key:"10ikf1"}]],Vb=He("play",Xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zb=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],Kb=He("plus",Zb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jb=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],Wb=He("rotate-ccw",Jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fb=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],Ib=He("shield-check",Fb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pb=[["path",{d:"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",key:"cjf0a3"}],["path",{d:"M7 2v20",key:"1473qp"}],["path",{d:"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7",key:"j28e5"}]],e0=He("utensils",Pb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const t0=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],pp=He("x",t0);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const l0=[["path",{d:"M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",key:"1q2vi4"}],["path",{d:"m10 15 5-3-5-3z",key:"1jp15x"}]],a0=He("youtube",l0),xp=q.createContext(null);function n0({payload:i,children:c}){return n.jsx(xp.Provider,{value:i,children:c})}function le(){const i=q.useContext(xp);if(!i)throw new Error("SiteProvider 밖에서 useSite() 를 불렀습니다. payload 없이는 렌더할 수 없습니다.");return i}function s0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.6",children:[n.jsx("rect",{x:"2",y:"5",width:"20",height:"14",rx:"2"}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"8.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M8.5 9.4v5.2M5.9 12h5.2"})]}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"15.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M15.5 9.4v5.2M12.9 12h5.2"})]}),n.jsx("path",{d:"M6 19l1.5-2.4h9L18 19"})]})}function i0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M8 5v14l11-7z"})})}function c0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"})})}function r0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[n.jsx("path",{d:"M4 7h11M4 12h11M4 17h7"}),n.jsx("path",{d:"M19 17.5V9l3 1.2"}),n.jsx("circle",{cx:"17.4",cy:"17.6",r:"1.7",fill:"currentColor",stroke:"none"})]})}function o0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",children:n.jsx("path",{d:"M6 6l12 12M18 6L6 18"})})}function Ah(i){if(typeof i!="number"||!Number.isFinite(i))return"";const c=Math.floor(i/60),o=Math.floor(i%60);return`${c}:${o<10?"0":""}${o}`}function u0(){const i=le(),c=(i.songs??[]).filter($=>$.audioUrl),[o,u]=q.useState(0),[f,d]=q.useState(!1),[h,p]=q.useState(!1),[g,x]=q.useState({now:0,total:0}),[v,y]=q.useState({}),z=q.useRef(null),w=q.useRef(null),N=q.useRef(null),A=c.length;q.useEffect(()=>{A>1&&u(Math.floor(Math.random()*A))},[A]);const D=q.useCallback(()=>{var ee;const $=document.querySelector("header"),K=$==null?void 0:$.getBoundingClientRect(),G={top:`${K?K.bottom+6:66}px`};if(window.innerWidth<=640)G.left="8px",G.right="8px";else{G.left="auto";const se=(ee=w.current)==null?void 0:ee.getBoundingClientRect();G.right=`${Math.max(12,window.innerWidth-((se==null?void 0:se.right)??0))}px`}y(G)},[]);if(q.useEffect(()=>{if(!h)return;D();const $=()=>D(),K=G=>{var se,Q;const ee=G.target;(se=N.current)!=null&&se.contains(ee)||(Q=w.current)!=null&&Q.contains(ee)||p(!1)};return window.addEventListener("resize",$),window.addEventListener("scroll",$,{passive:!0}),document.addEventListener("click",K),()=>{window.removeEventListener("resize",$),window.removeEventListener("scroll",$),document.removeEventListener("click",K)}},[h,D]),q.useEffect(()=>()=>{var $;return($=z.current)==null?void 0:$.pause()},[]),c.length===0)return null;const U=c[o]??c[0],X=($=o)=>{const K=z.current,G=c[$];!K||!G||(K.getAttribute("src")!==G.audioUrl&&(K.src=G.audioUrl),K.play().then(()=>d(!0),()=>d(!1)))},Z=()=>{var $;($=z.current)==null||$.pause(),d(!1)},P=$=>{const K=($%c.length+c.length)%c.length;u(K),x({now:0,total:0}),X(K)};return n.jsxs(n.Fragment,{children:[n.jsxs("span",{id:"w4d-mini",ref:w,"data-playing":f?"1":"0",children:[n.jsx("span",{id:"w4d-tape","aria-hidden":!0,title:`${i.place.name}이(가) 만든 노래`,children:n.jsx(s0,{})}),n.jsx("span",{id:"w4d-now","aria-live":"polite",children:U.title}),n.jsx("button",{type:"button",id:"w4d-play","data-playing":f?"1":"0",title:`${f?"멈춤":"재생"} · ${U.title}`,onClick:()=>f?Z():X(),children:f?n.jsx(c0,{}):n.jsx(i0,{})}),n.jsx("button",{type:"button",id:"w4d-toggle",title:"노래 목록","aria-expanded":h,onClick:()=>p($=>!$),children:n.jsx(r0,{})})]}),n.jsx("audio",{ref:z,src:c[0].audioUrl,preload:"none",onTimeUpdate:$=>x({now:$.currentTarget.currentTime,total:$.currentTarget.duration}),onDurationChange:$=>x(K=>({...K,total:$.currentTarget.duration})),onEnded:()=>P(o+1),onError:()=>d(!1)}),n.jsxs("div",{id:"w4d-panel",ref:N,hidden:!h,style:v,children:[n.jsxs("div",{className:"w4d-head",children:[n.jsxs("span",{className:"w4d-head-t",children:[i.place.name,"이(가) 만든 노래"]}),n.jsxs("span",{className:"w4d-head-n",children:[c.length,"곡"]}),n.jsx("button",{type:"button",id:"w4d-close",title:"닫기","aria-label":"닫기",onClick:()=>p(!1),children:n.jsx(o0,{})})]}),n.jsx("ol",{children:c.map(($,K)=>n.jsx("li",{children:n.jsxs("button",{type:"button",className:"w4d-item","aria-current":K===o,"data-playing":K===o&&f?"1":"0",onClick:()=>P(K),children:[n.jsxs("span",{className:"w4d-mark",children:[n.jsx("span",{className:"w4d-disc-sm"}),n.jsxs("span",{className:"w4d-eq","aria-hidden":!0,children:[n.jsx("i",{}),n.jsx("i",{}),n.jsx("i",{})]})]}),n.jsxs("span",{className:"w4d-info",children:[n.jsx("span",{className:"w4d-t",children:$.title}),n.jsx("span",{className:"w4d-sub",children:i.place.name})]}),n.jsx("span",{className:"w4d-time",children:K===o&&(f||g.now>0)?`${Ah(g.now)}${g.total?` / ${Ah(g.total)}`:""}`:""})]})},$.songId))}),n.jsx("div",{id:"w4d-lyrics-box",hidden:!U.lyrics,children:(U.lyrics??"").replace(/^\[.*\]$/gm,"").replace(/\n{3,}/g,` + +`).trim()})]})]})}const Ma={LODGING:1,CAFE:2,RESTAURANT:3,CLINIC:4},f0={OWNER:1,CRAWL:3,TEMPLATE:5},_h={UNVERIFIED:1,VERIFIED:3,CORRECTED:4},d0=[_h.VERIFIED,_h.CORRECTED];function gp(i){return d0.includes(i)}const bt={YANOLJA:1,GOODCHOICE:2,NAVER_PLACE:3,INSTAGRAM:4,OFFICIAL_SITE:5,BLOG:6,NAVER_BOOKING:7,ETC:99},S1={PUBLISHED:3};function Bs(i){return i.filter(c=>gp(c.status)&&c.value!=null&&c.value!=="")}function bp(i){const c=new Map;for(const o of Bs(i))c.set(o.key,o);return c}function ca(i,c){const o=bp(i).get(c);return(o==null?void 0:o.value)??void 0}function At(i,c){const o=bp(i).get(c);if(!(o!=null&&o.value))return;const u=o.type==="number"?m0(o.value):o.value;return o.unit?`${u}${o.unit}`:u}function m0(i){const c=Number(i.replace(/,/g,""));return Number.isFinite(c)?c.toLocaleString("ko-KR"):i}function Po(i){return i.type!=="bool"?i.label:i.label.replace(/\s*(가능|여부)$/,"")||i.label}function h0(i){return i.filter(c=>gp(c.status)&&c.question&&c.answer).sort((c,o)=>c.sortOrder-o.sortOrder)}function eu(i){return i.map(c=>({...c,facts:Bs(c.facts)})).sort((c,o)=>c.sortOrder-o.sortOrder)}const p0={songs:"title",daily:"title",course:"name",schedule:"name",people:"name",chronicle:"title",literature:"workTitle",postcard:"line",quiz:"question",planner:"name",itinerary:"name",video:"url",event:"title"};function x0(i){const c=(i??"").trim();if(!c)return;const o=/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec(c);return o==null?void 0:o[1]}function g0(i){return/youtube\.com\/shorts\//.test((i??"").trim())}function b0(i){return`https://i.ytimg.com/vi/${i}/hqdefault.jpg`}function v0(i){return`https://www.youtube-nocookie.com/embed/${i}?autoplay=1&rel=0&modestbranding=1`}const ic={items:[],unverified:0,sourced:0};function y0(i,c){var x,v;const o=y=>{const z=i.slice(0,Math.max(0,y)),w=z.split(` +`).length,N=y-z.lastIndexOf(` +`);return`${w}번째 줄 ${N}번째 글자`},u=(x=/Unexpected token '(.)'/.exec(c))==null?void 0:x[1],f=u==="}"||u==="]"?"닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.":"그 앞의 쉼표·따옴표·괄호를 확인해 주세요.",d=/line (\d+) column (\d+)/.exec(c);if(d)return`${d[1]}번째 줄 ${d[2]}번째 글자에서 JSON 이 끊깁니다. ${f}`;const h=/position (\d+)/.exec(c);if(h)return`${o(Number(h[1]))}에서 JSON 이 끊깁니다. ${f}`;const p=(v=/\.\.\."([\s\S]*?)" is not valid JSON/.exec(c))==null?void 0:v[1],g=p?i.indexOf(p):-1;return g>=0?`${o(g+p.length)} 부근에서 JSON 이 끊깁니다. ${f}`:"JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요."}function j0(i,c){const o=p0[i],u=(c??"").trim();if(!o||!u)return ic;let f;try{f=JSON.parse(u)}catch(y){return{...ic,error:y0(u,y instanceof Error?y.message:"")}}if(typeof f!="object"||f===null||Array.isArray(f))return{...ic,error:"바깥이 { } 로 감싸인 JSON 이어야 합니다."};const d=f,h=typeof d.kind=="string"?d.kind:void 0,p=d.items;if(!Array.isArray(p))return{...ic,error:"items 배열이 없습니다. 프롬프트로 다시 만들어 주세요."};const g=p.filter(y=>typeof y=="object"&&y!==null&&!Array.isArray(y)&&typeof y[o]=="string"&&y[o].trim().length>0);let x=0,v=0;for(const y of g){const z=y;z.verified!=="확인"&&(x+=1),z.source&&typeof z.source=="object"&&(v+=1)}return{items:g,title:typeof d.title=="string"?d.title:void 0,subtitle:typeof d.subtitle=="string"?d.subtitle:void 0,linkUrl:typeof d.linkUrl=="string"&&/^https?:\/\//.test(d.linkUrl)?d.linkUrl:void 0,linkLabel:typeof d.linkLabel=="string"?d.linkLabel:void 0,kindMismatch:h&&h!==i?h:void 0,unverified:x,sourced:v}}const N0=1260,S0=60,z0="10:00",Ch=["봄","여름","가을","겨울"];function Mh(i){const c=/^(\d{1,2}):(\d{2})$/.exec(i.trim());if(!c)return;const o=Number(c[1]),u=Number(c[2]);if(!(o>23||u>59))return o*60+u}function cc(i){const c=(i%1440+1440)%1440;return`${String(Math.floor(c/60)).padStart(2,"0")}:${String(c%60).padStart(2,"0")}`}function E0(i){const c=Mh(i.startTime??"")??Mh(z0)??600,o=[];let u=c,f=0;for(const d of i.stops??[]){const h=Math.max(0,d.moveMinutes??0),p=Math.max(1,d.minutes??S0),g=u+h;if(g+p>N0){f+=1;continue}o.push({stop:d,time:cc(g),until:cc(g+p),move:h}),u=g+p}return{stops:o,from:cc(c),to:cc(u),totalMinutes:u-c,dropped:f}}function w0(i=new Date){const c=i.getMonth()+1,o=Math.floor((c-3+12)%12/3),u=Ch[o];return c%3===0&&i.getDate()<=15?[Ch[(o+3)%4],u]:[u]}function kh(i){const c=parseInt(i.replace("#",""),16);return[c>>16&255,c>>8&255,c&255]}function Oh(i,c,o){if(!/^#[0-9a-fA-F]{6}$/.test(i)||!/^#[0-9a-fA-F]{6}$/.test(c))return i;const[u,f,d]=kh(i),[h,p,g]=kh(c),x=(y,z)=>Math.round(y+(z-y)*o),v=y=>y.toString(16).padStart(2,"0");return`#${v(x(u,h))}${v(x(f,p))}${v(x(d,g))}`}function T0(i){return{surface:Oh(i.bg,i.text,.06),surfaceAlt:i.card,inverse:"#1c1917",border:Oh(i.bg,i.text,.2)}}const vp={[Ma.LODGING]:{type:"HotelRoom",path:"rooms",label:"객실"},[Ma.CAFE]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.RESTAURANT]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.CLINIC]:{type:"Product",path:"programs",label:"시술"}};function A0(i){return i.links.filter(c=>c.confirmed&&/^https?:\/\//i.test(c.url??""))}function yp(i){var c;for(const[o,u]of[["weekday_price","주중 1박"],["price","1박"],["weekend_price","주말 1박"],["peak_price","성수기 1박"]]){const f=Number((c=ca(i.facts,o))==null?void 0:c.replace(/[^0-9]/g,""));if(Number.isFinite(f)&&f>0)return{price:f,label:u}}}const Dh=[bt.NAVER_BOOKING,bt.YANOLJA,bt.GOODCHOICE,bt.NAVER_PLACE];function dc(i){return Bs(i.facts).filter(c=>c.scope==="place").map(c=>({label:Po(c),value:tu(c,i.facts)})).filter(c=>c.value!=="")}function tu(i,c){const o=At(c,i.key);return o?i.type!=="bool"?o:/여부$/.test(i.label)?o==="true"?"있음":o==="false"?"없음":o:o==="true"?"가능":o==="false"?"불가":o:""}const _0=new Set(["weekday_price","weekend_price","peak_price","price_range"]);function fl(i){const c=vp[i.place.category],o=new Map(i.media.map(u=>[u.mediaId,u]));return eu(i.units).map(u=>{const f=At(u.facts,"room_intro")??At(u.facts,"description"),d=i.place.category===Ma.LODGING?["standard_capacity","max_capacity","bed_type","room_size"]:["price","volume","origin"];return{unitId:u.unitId,slug:u.slug,name:u.name,intro:f,chips:d.map(h=>{var x;const p=At(u.facts,h),g=((x=u.facts.find(v=>v.key===h))==null?void 0:x.label)??h;return p?{label:g,value:p}:null}).filter(h=>h!==null),rows:u.facts.filter(h=>!_0.has(h.key)).map(h=>({label:Po(h),value:tu(h,u.facts)})).filter(h=>h.value!==""),images:u.mediaIds.map(h=>o.get(h)).filter(h=>{var p;return!!((p=h==null?void 0:h.alt)!=null&&p.trim())}),priceText:C0(u),prices:k0(u),href:`/${c.path}/${u.slug}`}})}function C0(i){var d,h;const c=At(i.facts,"price_range");if(c)return c;const o=Number((d=At(i.facts,"weekday_price"))==null?void 0:d.replace(/[^0-9]/g,"")),u=Number((h=At(i.facts,"price"))==null?void 0:h.replace(/[^0-9]/g,"")),f=Number.isFinite(o)&&o>0?o:u;if(!(!Number.isFinite(f)||f<=0))return`${f.toLocaleString("ko-KR")}원부터`}const M0=[["weekday_price","주중"],["weekend_price","주말"],["peak_price","성수기"]];function k0(i){return At(i.facts,"price_range")?[]:At(i.facts,"weekday_price")?M0.map(([c,o])=>({label:o,value:At(i.facts,c)??"문의"})):[]}function O0(i){const c=i??"";return/눈|설/.test(c)?"눈":/비|우|소나기/.test(c)?"비":/흐림|구름|안개|박무/.test(c)?"흐림":"맑음"}function D0(i){return i>=30?"혹서":i>=25?"더움":i>=20?"선선":i>=10?"쌀쌀":"추움"}function lu(i){return i.media.filter(c=>{var o;return((o=c.alt)==null?void 0:o.trim())&&!c.unitId})}function R0(i){return h0(i.faqs)}function U0(i){return i.theme.sections.filter(c=>c.enabled)}function Kt(i,c){var o;return((o=i.theme.sections.find(u=>u.id===c))==null?void 0:o.enabled)??!1}function H0(i,c){var u;const o=(u=i.theme.sections.find(f=>f.id===c))==null?void 0:u.body;return(o==null?void 0:o.split(/\n\s*\n/).map(f=>f.trim()).filter(Boolean))??[]}function dl(i,c){var d;const o=i.theme.sections.find(h=>h.id===c),u=j0(c,o==null?void 0:o.data),f=(d=i.local.story)==null?void 0:d[c];return!Array.isArray(f)||f.length===0?u:{...u,items:[...u.items,...f]}}function ft(i){return vp[i.place.category]}function Ye(i,c,o){var f,d;const u=(d=(f=i.theme.sections.find(h=>h.id===c))==null?void 0:f.name)==null?void 0:d.trim();return u||o}function zn(i,c){const o=new Map(Bs(i.facts).filter(u=>u.scope==="place").map(u=>[u.key,u]));return c.map(u=>o.get(u)).filter(u=>u!==void 0).map(u=>({label:Po(u),value:tu(u,i.facts)})).filter(u=>u.value!=="")}function L0(i,c){return zn(i,[c])[0]}const B0=["reservation_required","reservation_channel"];function q0(i){return zn(i,B0)}const Y0=["seat_count","terrace","room_available","group_seat_max","power_outlet","study_allowed","wheelchair_accessible"];function G0(i){return zn(i,Y0)}const $0=["operating_hours","session_times","closed_days","age_limit","guide_language"];function Q0(i){return zn(i,$0)}const X0=["check_in_time","check_out_time","operating_hours","closed_days","parking","reservation_required"];function Oa(i){return zn(i,X0).slice(0,4)}function En(i){const c=fl(i).map(u=>u.priceText).filter(u=>!!u);if(c.length===0)return;const o=u=>Number(u.replace(/[^0-9]/g,""))||Number.MAX_SAFE_INTEGER;return c.reduce((u,f)=>o(f)<o(u)?f:u)}const jp={[bt.NAVER_BOOKING]:"네이버 예약",[bt.YANOLJA]:"야놀자",[bt.GOODCHOICE]:"여기어때",[bt.NAVER_PLACE]:"네이버 플레이스",[bt.INSTAGRAM]:"인스타그램",[bt.OFFICIAL_SITE]:"공식 사이트",[bt.BLOG]:"블로그",[bt.ETC]:"채널"};function ml(i){return i.title??jp[i.channel]??"채널"}function ks(i){return jp[i.channel]??ml(i)}const V0=[bt.NAVER_PLACE,bt.INSTAGRAM];function Np(i,c){return i.links.filter(o=>o.confirmed&&c.includes(o.channel))}function Ze(i){const c=new Map(Dh.map((o,u)=>[o,u]));return Np(i,Dh).filter(o=>!Z0(o.url)).sort((o,u)=>(c.get(o.channel)??99)-(c.get(u.channel)??99))}function Z0(i){return/\/p\/search\/|[?&]query=/.test(i)}const K0=["check_in_time","check_out_time","cancel_policy","extra_person_fee","reception_hours","cooking_allowed","pet_allowed","smoking"];function J0(i){return eu(i.units).map(c=>{const o=At(c.facts,"standard_capacity"),u=At(c.facts,"max_capacity"),f=yp(c);return{unitId:c.unitId,name:c.name,capacityText:[o&&`기준 ${o}`,u&&`최대 ${u}`].filter(Boolean).join(" · ")||void 0,rateRows:["weekday_price","weekend_price","peak_price"].map(d=>{var g;const h=At(c.facts,d),p=((g=c.facts.find(x=>x.key===d))==null?void 0:g.label)??d;return h?{label:p,value:h}:null}).filter(d=>d!==null),baseRateText:f?`${f.label} ${f.price.toLocaleString("ko-KR")}원`:void 0,href:"#units"}})}function W0(i){if(i.place.category!==Ma.LODGING)return null;const c={offers:J0(i).filter(u=>u.rateRows.length>0||u.capacityText!==void 0),notices:zn(i,K0),links:Ze(i),contacts:wn(i).filter(u=>!Ze(i).some(f=>f.url===u.url)),phone:i.place.phone};return c.offers.length===0&&c.notices.length===0&&c.links.length===0&&c.contacts.length===0&&!c.phone?null:c}function F0(i,c){return i.theme.sections.some(o=>o.id===c)}function wn(i){return Np(i,V0)}function ht(i,c){const o=ks(i),u=o.endsWith("예약")?o:`${o} 예약`;return c?`${c} ${u}`:u}function Sp(i){return i.channel===bt.NAVER_BOOKING?"네이버 예약으로 바로 예약하기":`${ml(i)}에서 예약`}function I0(){var u;const i=le(),c=ft(i),o=[{label:"소개",href:"#about",show:Kt(i,"intro")},{label:c.label,href:"#units",show:i.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"축제",href:"#festival",show:(((u=i.local.festivals)==null?void 0:u.length)??0)>0},{label:"주변 정보",href:"#guide",show:Kt(i,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:i.faqs.length>0}].filter(f=>f.show);return n.jsx("header",{className:"border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 88%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsxs("a",{href:"#top",className:"flex min-w-0 items-center gap-2.5",children:[n.jsx("span",{className:"serif flex size-8 shrink-0 items-center justify-center rounded-md text-[length:var(--fs-sm)]",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",fontWeight:"var(--tpl-heading-weight, 700)"},"aria-hidden":!0,children:i.place.name.slice(0,1)}),n.jsxs("span",{className:"flex min-w-0 flex-col",children:[n.jsx("span",{className:"serif truncate text-[length:var(--fs-lead)] leading-tight",style:{fontWeight:"var(--tpl-heading-weight, 700)"},children:i.place.name}),i.place.englishName&&n.jsx("span",{className:"text-muted truncate text-[10px] uppercase leading-tight tracking-[0.14em]",children:i.place.englishName})]})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-7 lg:flex",children:o.map(f=>n.jsx("a",{href:f.href,className:"text-muted py-1 text-[length:var(--fs-sm)] font-medium transition-colors hover:text-current",children:f.label},f.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1.5",children:[n.jsx(u0,{}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap inline-flex items-center gap-1.5 rounded-lg px-3 text-[length:var(--fs-xs)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{className:"hidden sm:inline",children:i.place.phone}),n.jsx("span",{className:"sr-only sm:hidden",children:"전화"})]}),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-lg marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line tpl-border absolute right-0 top-[calc(100%+0.5rem)] z-50 w-52 overflow-hidden rounded-xl border shadow-lg",style:{backgroundColor:"var(--tpl-surface, #fff)"},children:n.jsx("ul",{className:"divide-line divide-y",children:o.map(f=>n.jsx("li",{children:n.jsx("a",{href:f.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-medium",children:f.label})},f.label))})})]})]})]})})}function mc(i){return i.media.find(c=>{var o;return c.isPrimary&&((o=c.alt)==null?void 0:o.trim())})??i.media.find(c=>{var o;return(o=c.alt)==null?void 0:o.trim()})}function P0(i){return ev(i).join(" ")}function ev(i){const{place:c,facts:o}=i,u=[],f=c.addressLocality??c.addressRegion??c.roadAddress??c.address;u.push(f?`${f}에 있는 ${c.name}입니다.`:`${c.name}입니다.`);const d=ca(o,"check_in_time"),h=ca(o,"check_out_time");d&&h&&u.push(`체크인 ${d}, 체크아웃 ${h}.`);const p=ca(o,"business_hours")??ca(o,"open_hours");p&&u.push(`영업시간 ${p}.`);const g=ca(o,"pet_allowed");if(g==="false"&&u.push("반려동물 동반 불가."),g==="true"&&u.push("반려동물 동반 가능."),ca(o,"parking")==="true"){const v=ca(o,"parking_capacity");u.push(v?`주차 ${v}대 가능.`:"주차 가능.")}return c.phone&&u.push(`문의 ${c.phone}.`),u}const tv=6e3,lv=5;function av(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=c.addressLocality??c.addressRegion,p=[...i.media].filter(N=>{var A;return(A=N.alt)==null?void 0:A.trim()}).sort((N,A)=>Number(A.isPrimary)-Number(N.isPrimary)).slice(0,lv),[g,x]=q.useState(0),[v,y]=q.useState(!1),z=q.useRef(null),w=q.useCallback(N=>x(A=>(A+N+p.length)%p.length),[p.length]);return q.useEffect(()=>{if(v||p.length<2)return;const N=setInterval(()=>w(1),tv);return()=>clearInterval(N)},[v,w,p.length]),n.jsxs("section",{id:"top",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"clamp(24rem, 76vh, 42rem)"},onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),onFocusCapture:()=>y(!0),onBlurCapture:()=>y(!1),onTouchStart:N=>{z.current=N.touches[0].clientX},onTouchEnd:N=>{const A=z.current;if(z.current=null,A===null)return;const D=N.changedTouches[0].clientX-A;Math.abs(D)>40&&w(D<0?1:-1)},children:[p.map((N,A)=>n.jsx("img",{src:N.url,alt:N.alt,fetchPriority:A===0?"high":"low",loading:A===0?void 0:"lazy",decoding:"async",className:"absolute inset-0 size-full object-cover object-center transition-opacity duration-700",style:{opacity:A===g?1:0}},N.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 45%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 60%, transparent) 100%)"}}),n.jsxs("div",{className:"pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-6 text-center",children:[h&&n.jsx("p",{className:"label opacity-85",children:h}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),(o.tagline??o.heroSubline)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90",children:o.tagline??o.heroSubline})]}),p.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Rh,{dir:"prev",onClick:()=>w(-1)}),n.jsx(Rh,{dir:"next",onClick:()=>w(1)}),n.jsx("ul",{className:"absolute inset-x-0 bottom-5 flex items-center justify-center gap-2",children:p.map((N,A)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>x(A),"aria-label":`${A+1}번째 사진`,"aria-current":A===g,className:`h-1.5 rounded-full bg-current transition-all ${A===g?"w-7 opacity-90":"w-1.5 opacity-45 hover:opacity-70"}`})},N.mediaId))})]})]}),(f||u.length>0||d.length>0||c.phone)&&n.jsx("div",{className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsxs("div",{className:"shell flex flex-col items-center gap-4 py-4 lg:flex-row lg:justify-between lg:gap-8",children:[n.jsxs("dl",{className:"divide-line flex flex-wrap justify-center gap-y-2 sm:divide-x",children:[f&&n.jsxs("div",{className:"px-5 text-center sm:first:pl-0",children:[n.jsx("dt",{className:"label",children:"1박 최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:f})]}),u.map(N=>n.jsxs("div",{className:"px-5 text-center",children:[n.jsx("dt",{className:"label",children:N.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:N.value})]},N.label))]}),n.jsxs("div",{className:"flex shrink-0 gap-2",children:[c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border inline-flex items-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:c.phone})]}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]})]})})]})}function Rh({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full bg-current/15 px-3 backdrop-blur-sm transition-colors hover:bg-current/25 sm:inline-flex ${i==="prev"?"left-4":"right-4"}`,children:n.jsx(o,{className:"size-5"})})}function nv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i).slice(0,3),f=En(i),d=Ze(i),h=L0(i,"extra_person_fee"),p=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary))[0],g=c.addressLocality??c.addressRegion;return n.jsx("section",{id:"top",className:"border-line w-full border-b",children:n.jsxs("div",{className:"grid lg:grid-cols-2",children:[n.jsx("div",{className:"relative order-1 min-h-[16rem] lg:order-none lg:min-h-[34rem]",children:p&&n.jsx("img",{src:p.url,alt:p.alt,fetchPriority:"high",decoding:"async",className:"absolute inset-0 size-full object-cover"})}),n.jsxs("div",{className:"order-2 flex flex-col justify-center gap-6 px-6 py-10 sm:px-10 lg:order-none lg:px-14 lg:py-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:[n.jsxs("div",{children:[g&&n.jsx("p",{className:"label",children:g}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80",children:o.tagline??o.heroSubline??o.summary})]}),n.jsxs("div",{className:"panel p-5 sm:p-6",children:[f&&n.jsxs("p",{className:"flex items-baseline gap-2",children:[n.jsx("span",{className:"text-[length:var(--fs-h3)] font-bold tabular-nums",children:f}),n.jsx("span",{className:"text-muted text-[length:var(--fs-xs)]",children:"1박 기준"})]}),u.length>0&&n.jsxs("dl",{className:"divide-line mt-4 divide-y border-t pt-1",children:[u.map(x=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{className:"font-semibold",children:x.value})]},x.label)),h&&n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:h.label}),n.jsx("dd",{className:"font-semibold tabular-nums",children:h.value})]})]}),n.jsxs("div",{className:"mt-5 flex flex-col gap-2 sm:flex-row",children:[d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])}),c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:c.phone})]})]})]})]})]})})}const sv={"stay-reservation":"reservation","stay-oasi":"oasi","stay-studio":"studio","stay-pastel":"pastel","stay-editorial":"editorial"};function zp(i){return sv[i??""]??"default"}function hc(){return zp(le().theme.templateId)}const rc=5,Xo=1.8,iv=3;function cv(i){const c=i*rc,o=f=>`${(f/c*100).toFixed(3)}%`;return`${i<2?"":` +.stay2-hero-slide { animation: stay2-hero-fade ${c}s ease-in-out infinite; } +@keyframes stay2-hero-fade { + 0% { opacity: 1; } + ${o(rc)} { opacity: 1; } + ${o(rc+Xo)} { opacity: 0; } + ${o(c-Xo)} { opacity: 0; } + 100% { opacity: 1; } +}`} +.stay2-hero-focus { animation: stay2-hero-focus 1.6s cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes stay2-hero-focus { + 0% { opacity: 0; filter: blur(0.22em); } + 40% { opacity: 1; } + 100% { opacity: 1; filter: blur(0); } +} +@media (prefers-reduced-motion: reduce) { + .stay2-hero-slide, .stay2-hero-focus { animation: none; } +} +`}function rv(i,c){return((c-i)%c*rc+Xo).toFixed(1)}function ov(){const i=le(),{place:c,narrative:o}=i,u=ft(i),f=Ze(i)[0],d=o.tagline??o.heroSubline,h=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)),p=h.length<2?0:Math.min(h.length,iv),g=f?{href:f.url,label:`${ks(f)}에서 예약`,external:!0}:c.phone?{href:`tel:${c.phone}`,label:`${c.phone} 예약 문의`,external:!1}:i.units.length>0?{href:"#units",label:`${u.label} 보기`,external:!1}:null;return n.jsxs("section",{id:"top",className:"w-full",children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:cv(p)}}),n.jsxs("div",{className:"relative flex w-full items-center justify-center overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"100svh"},children:[h.map((x,v)=>n.jsx("img",{src:x.url,alt:x.alt,fetchPriority:v===0?"high":v<p?"auto":"low",loading:v<Math.max(p,1)?void 0:"lazy",decoding:"async",width:x.width,height:x.height,className:`absolute inset-0 size-full object-cover object-center ${v<p?"stay2-hero-slide":""}`,style:{opacity:v===0?1:0,animationDelay:v<p?`-${rv(v,p)}s`:void 0}},x.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 14%, transparent) 40%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 h-48",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 68%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 40%, transparent) 45%, transparent 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"radial-gradient(ellipse 58% 42% at 50% 52%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 38%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 18%, transparent) 55%, transparent 78%)"}}),n.jsxs("div",{className:"shell relative z-10 flex flex-col items-center pt-20 text-center",style:{textShadow:"0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)"},children:[n.jsx("h1",{className:"stay2-hero-focus serif",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.06em",lineHeight:1.25,animationDelay:"0.05s"},children:c.name}),d&&n.jsx("p",{className:"stay2-hero-focus measure mt-6 text-[length:var(--fs-lead)] font-light leading-loose opacity-95",style:{animationDelay:"0.3s"},children:d}),g&&n.jsx("a",{href:g.href,...g.external?{target:"_blank",rel:"noopener noreferrer"}:{},className:"stay2-hero-focus tap mt-10 inline-flex items-center justify-center border border-current/70 px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-colors hover:border-current",style:{animationDelay:"0.55s",backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 22%, transparent)",boxShadow:"0 1px 6px color-mix(in srgb, var(--tpl-inverse, #1c1917) 30%, transparent)"},children:g.label})]})]})]})}function uv(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=En(i),d=Ze(i),h=Oa(i),p=h.length>0?h:dc(i).slice(0,4),g=(u==null?void 0:u.caption)??c.name;return n.jsxs("section",{id:"top","data-oasi":!0,"aria-labelledby":"top-heading",className:"pt-12 lg:pt-16",children:[n.jsx("h1",{id:"top-heading",className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em] leading-[1.9]",children:c.name}),o.heroHeadline&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o.heroHeadline}),u&&n.jsxs("figure",{className:"relative mx-auto mt-10 w-full max-w-[34rem]",children:[n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"block h-[clamp(17rem,27vw,24rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:g})]}),(f||p.length>0)&&n.jsxs("dl",{className:"border-line mt-10 flex flex-wrap gap-x-10 gap-y-4 border-t pt-5 text-[length:var(--fs-xs)] leading-[1.9]",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:"최저가"}),n.jsx("dd",{children:f})]}),p.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{children:x.value})]},x.label))]}),d.length>0&&n.jsx("ul",{className:"mt-6 flex flex-wrap items-center gap-x-8",children:d.map(x=>n.jsx("li",{children:n.jsx("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(x)})},x.url))})]})}function fv(){const i=le(),c=lu(i),o=mc(i),u=o&&c.some(f=>f.mediaId===o.mediaId)?[o,...c.filter(f=>f.mediaId!==o.mediaId)]:c;return u.length===0?null:n.jsx("section",{id:"top","aria-label":`${i.place.name} 사진`,className:"w-full",children:n.jsx("div",{className:"columns-1 gap-2 sm:columns-2",children:u.map((f,d)=>n.jsx("figure",{className:"mb-2 break-inside-avoid",children:n.jsx("img",{src:f.url,alt:f.alt,loading:d===0?"eager":"lazy",fetchPriority:d===0?"high":void 0,decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})})}function dv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=o.tagline??o.summary,p=c.addressLocality??c.addressRegion,g=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)).slice(0,3);return n.jsxs("section",{id:"top","data-pastel":"own",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--pastel-1)"},children:[n.jsxs("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-0",children:[n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(12rem, 26vw, 22rem)",aspectRatio:"1",backgroundColor:"var(--pastel-2)",right:"-6%",top:"-6%",opacity:.75}}),n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(8rem, 18vw, 15rem)",aspectRatio:"1",backgroundColor:"var(--pastel-3)",left:"46%",bottom:"-10%",opacity:.7}})]}),n.jsxs("div",{className:"shell relative grid items-center gap-8 lg:grid-cols-2 lg:gap-14",style:{paddingBlock:"var(--section-space)"},children:[n.jsxs("div",{className:"order-2 lg:order-1",children:[p&&n.jsx("p",{className:"inline-flex items-center rounded-full px-3 py-1 text-[length:var(--fs-xs)] font-extrabold",style:{backgroundColor:"var(--tpl-bg, #fff)",color:"var(--pastel-ink)"},children:p}),n.jsx("h1",{className:"mt-4 break-keep",style:{fontSize:"var(--fs-display)",fontWeight:800,lineHeight:1.2,letterSpacing:"var(--tpl-heading-tracking, -0.03em)"},children:c.name}),h&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80",children:h}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]}),n.jsxs("div",{className:"order-1 lg:order-2",children:[g[0]&&n.jsx("div",{className:"relative w-full overflow-hidden",style:{aspectRatio:"1 / 1",borderRadius:"calc(var(--tpl-radius, 24px) * 1.6)",boxShadow:"0 0 0 clamp(6px, 1.4vw, 12px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:g[0].url,alt:g[0].alt,fetchPriority:"high",decoding:"async",className:"size-full object-cover"})}),g.length>1&&n.jsx("ul",{className:"mt-3 grid grid-cols-2 gap-3",children:g.slice(1).map(x=>n.jsx("li",{className:"relative overflow-hidden",style:{aspectRatio:"4 / 3",borderRadius:"var(--tpl-radius, 24px)",boxShadow:"0 0 0 clamp(4px, 1vw, 8px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:x.url,alt:x.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},x.mediaId))})]})]})]}),(f||u.length>0)&&n.jsx("div",{className:"w-full",style:{backgroundColor:"var(--pastel-2)"},children:n.jsxs("dl",{className:"shell flex flex-wrap items-center justify-center gap-x-12 gap-y-5 py-8 text-center",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:"최저가"}),n.jsx("dd",{className:"mt-1 font-extrabold tabular-nums",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:f})]}),u.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:x.label}),n.jsx("dd",{className:"mt-1 font-extrabold",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:x.value})]},x.label))]})})]})}const Os="repeating-linear-gradient(90deg, color-mix(in oklab, currentColor 3%, transparent) 0 1px, transparent 1px 6px),repeating-linear-gradient(0deg, color-mix(in oklab, currentColor 2%, transparent) 0 1px, transparent 1px 6px)",mv={intro:"about",info:"info",rooms:"units",menu:"units",programs:"units",pricing:"pricing",booking:"booking",space:"space",inquiry:"inquiry",exhibition:"exhibition",photos:"gallery",local:"guide",weather:"weather",map:"location",faq:"faq",songs:"songs",daily:"daily",chronicle:"chronicle",people:"people",quiz:"quiz",postcard:"postcard",video:"video",itinerary:"itinerary"},hv={about:"소개",info:"이용 정보",pricing:"요금",booking:"예약 안내",space:"공간",inquiry:"문의",exhibition:"관람 안내",gallery:"사진",guide:"주변",weather:"날씨",location:"오시는 길",faq:"자주 묻는 질문",songs:"노래",daily:"일력",chronicle:"연표",people:"인물",quiz:"퀴즈",postcard:"엽서",video:"영상",itinerary:"일정"};function Uh(i){return String(i).padStart(2,"0")}function Ep(){var f;const i=le(),c=ft(i),o=new Set,u=[];for(const d of U0(i)){const h=mv[d.id];if(!h||o.has(h)||h==="units"&&i.units.length===0)continue;o.add(h);const p=(f=d.name)==null?void 0:f.trim();u.push({no:Uh(u.length+1),label:p||hv[h]||c.label,anchor:h})}return o.has("location")||u.push({no:Uh(u.length+1),label:"오시는 길",anchor:"location"}),u}function au({id:i,title:c,lead:o,aside:u}){var d;const f=(d=Ep().find(h=>h.anchor===i))==null?void 0:d.no;return n.jsxs("header",{className:"mb-7 sm:mb-10",children:[n.jsx("div",{"aria-hidden":!0,className:"mb-5 h-0.5 w-full bg-current opacity-75"}),n.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-10",children:[n.jsxs("div",{className:"relative min-w-0 flex-1",children:[f&&n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 select-none leading-none tabular-nums",style:{fontSize:"clamp(2.5rem, 7vw, 5rem)",fontWeight:800,opacity:.12},children:f}),n.jsx("h2",{id:`${i}-heading`,className:"h2 relative pl-9 sm:pl-14 xl:pl-20",style:{fontWeight:800},children:c}),o&&n.jsx("p",{className:"text-muted measure relative mt-2 pl-9 font-serif text-[length:var(--fs-sm)] leading-relaxed sm:pl-14 xl:pl-20",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})]})}function pv(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=ft(i),d=fl(i),h=Oa(i),p=En(i),g=Ze(i)[0],x=wn(i)[0],v=o.tagline??o.heroSubline,z=[c.addressSubLocality??c.addressLocality??c.addressRegion,d.length>0?`${f.label} ${d.length}개`:void 0,p].filter(w=>!!w);return n.jsx("section",{id:"top",className:"relative w-full",style:{backgroundImage:Os},children:n.jsxs("div",{className:"grid grid-cols-12",children:[u&&n.jsx("div",{className:"relative col-span-12 xl:col-span-8 xl:col-start-5 xl:row-start-1",style:{minHeight:"clamp(15rem, 52vh, 34rem)",backgroundColor:"var(--tpl-inverse)"},children:n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"absolute inset-0 size-full object-cover"})}),n.jsx("div",{className:"z-10 col-span-12 xl:col-start-1 xl:row-start-1 xl:mb-12 xl:mr-16 xl:self-end",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse) 88%, transparent)",color:"var(--tpl-bg)"},children:n.jsx("h1",{className:"serif px-4 py-5 sm:px-6 xl:py-8 xl:pl-10 xl:pr-16",style:{fontSize:"clamp(2.25rem, 7.5vw, 5.5rem)",fontWeight:800,lineHeight:.98,letterSpacing:"-0.035em"},children:c.name})}),n.jsxs("div",{className:"col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8",children:[v&&n.jsx("p",{className:"measure font-serif text-[length:var(--fs-lead)] leading-relaxed",children:v}),z.length>0&&n.jsx("p",{className:"text-[length:var(--fs-sm)] font-semibold tabular-nums",children:z.join(" · ")}),(g||c.phone||x)&&n.jsxs("div",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(g)}),c.phone&&n.jsx("a",{href:`tel:${c.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:c.phone}),x&&n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:[ml(x)," 문의"]})]}),h.length>0&&n.jsx("dl",{className:"mt-1",children:h.map(w=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-4 border-t py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:w.label}),n.jsx("dd",{className:"text-right font-semibold",children:w.value})]},w.label))})]})]})})}function xv(){var g;const i=le(),c=hc();if(c==="reservation")return n.jsx(ov,{});if(c==="oasi")return n.jsx(uv,{});if(c==="studio")return n.jsx(fv,{});if(c==="pastel")return n.jsx(dv,{});if(c==="editorial")return n.jsx(pv,{});const o=(g=i.theme.sections.find(x=>x.id==="hero"))==null?void 0:g.variantId;if(o==="hero.slideshow")return n.jsx(av,{});if(o==="hero.split")return n.jsx(nv,{});const{place:u,narrative:f}=i,d=mc(i),h=ft(i);Ze(i);const p=u.addressLocality??u.addressRegion;return n.jsx("section",{id:"top",className:"w-full",children:n.jsxs("div",{className:"relative flex w-full items-end overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",minHeight:"clamp(24rem, 62vh, 36rem)"},children:[d&&n.jsxs("div",{className:"absolute inset-0 z-0",children:[n.jsx("img",{src:d.url,alt:d.alt,fetchPriority:"high",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover object-center"}),n.jsx("div",{className:"absolute inset-0",style:{background:"linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)"}})]}),n.jsxs("div",{className:"shell relative z-10 pb-10 pt-24 sm:pb-12",children:[p&&n.jsxs("p",{className:"mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80",children:[n.jsx(Ls,{className:"size-3.5"}),n.jsx("span",{children:p})]}),n.jsx("h1",{className:"serif",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:u.name}),(f.tagline??f.heroSubline??f.summary)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85",children:f.tagline??f.heroSubline??f.summary}),i.units.length>0&&n.jsxs("a",{href:"#units",className:"mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:[n.jsxs("span",{children:[h.label," 보기"]}),n.jsx(fc,{className:"size-4"})]})]})]})})}function nu(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":`${c.getFullYear()}년 ${c.getMonth()+1}월 ${c.getDate()}일`}function qs(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":c.toLocaleDateString("sv-SE",{timeZone:"Asia/Seoul"})}function Vo(i){return`https://search.naver.com/search.naver?query=${encodeURIComponent(i)}`}function gv(i){return`https://www.youtube.com/results?search_query=${encodeURIComponent(i)}`}function su(i){return i.replace(/,/g," ").replace(/\s+/g," ").trim()}function bv(i,c,o){return c==null||o==null?`https://map.kakao.com/link/search/${encodeURIComponent(i)}`:`https://map.kakao.com/link/to/${encodeURIComponent(su(i))},${c},${o}`}function vv(i,c){const o=u=>`${u.lng},${u.lat},${encodeURIComponent(su(u.name))}`;return`https://map.naver.com/p/directions/${o(i)}/${o(c)}/-/car`}function wp(i,c,o){return c==null||o==null?`https://map.naver.com/p/search/${encodeURIComponent(i)}`:`https://map.naver.com/p/directions/-/${o},${c},${encodeURIComponent(su(i))}/-/transit`}function yv(i,c,o=16/9,u=.0022){const f=Math.max(Math.cos(i*Math.PI/180),.01),d=o*u/f;return`https://www.openstreetmap.org/export/embed.html?bbox=${[c-d,i-u,c+d,i+u].map(p=>p.toFixed(6)).join(",")}&layer=mapnik&marker=${i},${c}`}function jv(i,c,o){if(c==null||o==null)return;const u=encodeURIComponent(i);return`tmap://route?goalname=${u}&goalx=${o}&goaly=${c}&rGoName=${u}&rGoX=${o}&rGoY=${c}`}function Hh(){const i=le(),c=dc(i).slice(0,6),o=i.narrative.summary??P0(i);return c.length===0&&!o?null:n.jsx("section",{id:"summary","aria-labelledby":"summary-heading",className:"border-line w-full border-b py-10 sm:py-14",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsx("div",{className:"shell",children:n.jsxs("div",{className:"grid gap-8 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h2",{id:"summary-heading",className:"h3",children:"예약 전 확인"}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed",children:o}),n.jsxs("p",{className:"text-muted mt-3 text-[length:var(--fs-xs)]",children:[nu(i.site.updatedAt)," 기준, 사업자가 확인한 정보입니다."]})]}),c.length>0&&n.jsx("dl",{className:"grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2",children:c.map(u=>n.jsxs("div",{className:"border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28",children:u.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:u.value})]},u.label))})]})})})}function iu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-10 flex flex-col items-center gap-4 text-center sm:mb-14",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif max-w-full",style:{fontSize:"var(--fs-h2)",fontWeight:300,letterSpacing:"0.04em",lineHeight:1.35},children:c}),n.jsx("i",{"aria-hidden":!0,className:"block h-px w-10 bg-current opacity-25"}),o&&n.jsx("p",{className:"text-muted measure text-[length:var(--fs-sm)] leading-loose",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-xs)]",children:u})]})}function cu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-8",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:["[",c,"]"]}),o&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})}function ru({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-7 sm:mb-10",children:n.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-10",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif min-w-0 font-extrabold",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.01em",lineHeight:1.2,color:"var(--tpl-text)"},children:c}),o&&n.jsx("p",{className:"text-muted measure mt-2 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})})}function ou({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col items-center gap-3 text-center sm:mb-10",children:[n.jsx("i",{"aria-hidden":!0,className:"block h-[6px] w-10 rounded-full",style:{backgroundColor:"var(--pastel-2, currentColor)",opacity:.9}}),n.jsx("h2",{id:`${i}-heading`,className:"h2 break-keep",style:{fontSize:"calc(var(--fs-h2) * 1.15)",fontWeight:800},children:c}),o&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-65",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-sm)]",children:u})]})}const Nv={base:{backgroundColor:"var(--tpl-surface, #ffffff)"},alt:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},dark:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"}};function vt({id:i,title:c,lead:o,tone:u="base",aside:f,footnote:d,children:h,wide:p,bare:g}){const x=hc(),v=x==="reservation"?iu:x==="oasi"?cu:x==="studio"?ru:x==="pastel"?ou:x==="editorial"?au:Sv;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{...Nv[u],paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:p?"w-full":"shell",children:[!g&&n.jsx("div",{className:p?"shell":void 0,children:n.jsx(v,{id:i,title:c,lead:o,aside:f})}),h,d&&n.jsx("p",{className:`text-muted mt-5 text-[length:var(--fs-xs)] ${p?"shell":""}`,children:d})]})})}function Sv({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-6 sm:mb-8",children:n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"h2 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:c})]}),o&&n.jsx("p",{className:"text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})})}function zv(i){return Object.prototype.toString.call(i)==="[object Object]"}function Lh(i){return zv(i)||Array.isArray(i)}function Ev(){return!!(typeof window<"u"&&window.document&&window.document.createElement)}function uu(i,c){const o=Object.keys(i),u=Object.keys(c);if(o.length!==u.length)return!1;const f=JSON.stringify(Object.keys(i.breakpoints||{})),d=JSON.stringify(Object.keys(c.breakpoints||{}));return f!==d?!1:o.every(h=>{const p=i[h],g=c[h];return typeof p=="function"?`${p}`==`${g}`:!Lh(p)||!Lh(g)?p===g:uu(p,g)})}function Bh(i){return i.concat().sort((c,o)=>c.name>o.name?1:-1).map(c=>c.options)}function wv(i,c){if(i.length!==c.length)return!1;const o=Bh(i),u=Bh(c);return o.every((f,d)=>{const h=u[d];return uu(f,h)})}function fu(i){return typeof i=="number"}function Zo(i){return typeof i=="string"}function pc(i){return typeof i=="boolean"}function qh(i){return Object.prototype.toString.call(i)==="[object Object]"}function $e(i){return Math.abs(i)}function du(i){return Math.sign(i)}function As(i,c){return $e(i-c)}function Tv(i,c){if(i===0||c===0||$e(i)<=$e(c))return 0;const o=As($e(i),$e(c));return $e(o/i)}function Av(i){return Math.round(i*100)/100}function Ds(i){return Rs(i).map(Number)}function el(i){return i[Ys(i)]}function Ys(i){return Math.max(0,i.length-1)}function mu(i,c){return c===Ys(i)}function Yh(i,c=0){return Array.from(Array(i),(o,u)=>c+u)}function Rs(i){return Object.keys(i)}function Tp(i,c){return[i,c].reduce((o,u)=>(Rs(u).forEach(f=>{const d=o[f],h=u[f],p=qh(d)&&qh(h);o[f]=p?Tp(d,h):h}),o),{})}function Ko(i,c){return typeof c.MouseEvent<"u"&&i instanceof c.MouseEvent}function _v(i,c){const o={start:u,center:f,end:d};function u(){return 0}function f(g){return d(g)/2}function d(g){return c-g}function h(g,x){return Zo(i)?o[i](g):i(c,g,x)}return{measure:h}}function Us(){let i=[];function c(f,d,h,p={passive:!0}){let g;if("addEventListener"in f)f.addEventListener(d,h,p),g=()=>f.removeEventListener(d,h,p);else{const x=f;x.addListener(h),g=()=>x.removeListener(h)}return i.push(g),u}function o(){i=i.filter(f=>f())}const u={add:c,clear:o};return u}function Cv(i,c,o,u){const f=Us(),d=1e3/60;let h=null,p=0,g=0;function x(){f.add(i,"visibilitychange",()=>{i.hidden&&N()})}function v(){w(),f.clear()}function y(D){if(!g)return;h||(h=D,o(),o());const U=D-h;for(h=D,p+=U;p>=d;)o(),p-=d;const X=p/d;u(X),g&&(g=c.requestAnimationFrame(y))}function z(){g||(g=c.requestAnimationFrame(y))}function w(){c.cancelAnimationFrame(g),h=null,p=0,g=0}function N(){h=null,p=0}return{init:x,destroy:v,start:z,stop:w,update:o,render:u}}function Mv(i,c){const o=c==="rtl",u=i==="y",f=u?"y":"x",d=u?"x":"y",h=!u&&o?-1:1,p=v(),g=y();function x(N){const{height:A,width:D}=N;return u?A:D}function v(){return u?"top":o?"right":"left"}function y(){return u?"bottom":o?"left":"right"}function z(N){return N*h}return{scroll:f,cross:d,startEdge:p,endEdge:g,measureSize:x,direction:z}}function ka(i=0,c=0){const o=$e(i-c);function u(x){return x<i}function f(x){return x>c}function d(x){return u(x)||f(x)}function h(x){return d(x)?u(x)?i:c:x}function p(x){return o?x-o*Math.ceil((x-c)/o):x}return{length:o,max:c,min:i,constrain:h,reachedAny:d,reachedMax:f,reachedMin:u,removeOffset:p}}function Ap(i,c,o){const{constrain:u}=ka(0,i),f=i+1;let d=h(c);function h(z){return o?$e((f+z)%f):u(z)}function p(){return d}function g(z){return d=h(z),y}function x(z){return v().set(p()+z)}function v(){return Ap(i,p(),o)}const y={get:p,set:g,add:x,clone:v};return y}function kv(i,c,o,u,f,d,h,p,g,x,v,y,z,w,N,A,D,U,X){const{cross:Z,direction:P}=i,$=["INPUT","SELECT","TEXTAREA"],K={passive:!1},G=Us(),ee=Us(),se=ka(50,225).constrain(w.measure(20)),Q={mouse:300,touch:400},ae={mouse:500,touch:600},ye=N?43:25;let Ge=!1,Qe=0,ce=0,k=!1,Y=!1,te=!1,he=!1;function je(I){if(!X)return;function Te(tt){(pc(X)||X(I,tt))&&fe(tt)}const ke=c;G.add(ke,"dragstart",tt=>tt.preventDefault(),K).add(ke,"touchmove",()=>{},K).add(ke,"touchend",()=>{}).add(ke,"touchstart",Te).add(ke,"mousedown",Te).add(ke,"touchcancel",ze).add(ke,"contextmenu",ze).add(ke,"click",Ee,!0)}function S(){G.clear(),ee.clear()}function L(){const I=he?o:c;ee.add(I,"touchmove",oe,K).add(I,"touchend",ze).add(I,"mousemove",oe,K).add(I,"mouseup",ze)}function V(I){const Te=I.nodeName||"";return $.includes(Te)}function J(){return(N?ae:Q)[he?"mouse":"touch"]}function re(I,Te){const ke=y.add(du(I)*-1),tt=v.byDistance(I,!N).distance;return N||$e(I)<se?tt:D&&Te?tt*.5:v.byIndex(ke.get(),0).distance}function fe(I){const Te=Ko(I,u);he=Te,te=N&&Te&&!I.buttons&&Ge,Ge=As(f.get(),h.get())>=2,!(Te&&I.button!==0)&&(V(I.target)||(k=!0,d.pointerDown(I),x.useFriction(0).useDuration(0),f.set(h),L(),Qe=d.readPoint(I),ce=d.readPoint(I,Z),z.emit("pointerDown")))}function oe(I){if(!Ko(I,u)&&I.touches.length>=2)return ze(I);const ke=d.readPoint(I),tt=d.readPoint(I,Z),yt=As(ke,Qe),Jt=As(tt,ce);if(!Y&&!he&&(!I.cancelable||(Y=yt>Jt,!Y)))return ze(I);const ll=d.pointerMove(I);yt>A&&(te=!0),x.useFriction(.3).useDuration(.75),p.start(),f.add(P(ll)),I.preventDefault()}function ze(I){const ke=v.byDistance(0,!1).index!==y.get(),tt=d.pointerUp(I)*J(),yt=re(P(tt),ke),Jt=Tv(tt,yt),ll=ye-10*Jt,Ht=U+Jt/50;Y=!1,k=!1,ee.clear(),x.useDuration(ll).useFriction(Ht),g.distance(yt,!N),he=!1,z.emit("pointerUp")}function Ee(I){te&&(I.stopPropagation(),I.preventDefault(),te=!1)}function at(){return k}return{init:je,destroy:S,pointerDown:at}}function Ov(i,c){let u,f;function d(y){return y.timeStamp}function h(y,z){const N=`client${(z||i.scroll)==="x"?"X":"Y"}`;return(Ko(y,c)?y:y.touches[0])[N]}function p(y){return u=y,f=y,h(y)}function g(y){const z=h(y)-h(f),w=d(y)-d(u)>170;return f=y,w&&(u=y),z}function x(y){if(!u||!f)return 0;const z=h(f)-h(u),w=d(y)-d(u),N=d(y)-d(f)>170,A=z/w;return w&&!N&&$e(A)>.1?A:0}return{pointerDown:p,pointerMove:g,pointerUp:x,readPoint:h}}function Dv(){function i(o){const{offsetTop:u,offsetLeft:f,offsetWidth:d,offsetHeight:h}=o;return{top:u,right:f+d,bottom:u+h,left:f,width:d,height:h}}return{measure:i}}function Rv(i){function c(u){return i*(u/100)}return{measure:c}}function Uv(i,c,o,u,f,d,h){const p=[i].concat(u);let g,x,v=[],y=!1;function z(D){return f.measureSize(h.measure(D))}function w(D){if(!d)return;x=z(i),v=u.map(z);function U(X){for(const Z of X){if(y)return;const P=Z.target===i,$=u.indexOf(Z.target),K=P?x:v[$],G=z(P?i:u[$]);if($e(G-K)>=.5){D.reInit(),c.emit("resize");break}}}g=new ResizeObserver(X=>{(pc(d)||d(D,X))&&U(X)}),o.requestAnimationFrame(()=>{p.forEach(X=>g.observe(X))})}function N(){y=!0,g&&g.disconnect()}return{init:w,destroy:N}}function Hv(i,c,o,u,f,d){let h=0,p=0,g=f,x=d,v=i.get(),y=0;function z(){const K=u.get()-i.get(),G=!g;let ee=0;return G?(h=0,o.set(u),i.set(u),ee=K):(o.set(i),h+=K/g,h*=x,v+=h,i.add(h),ee=v-y),p=du(ee),y=v,$}function w(){const K=u.get()-c.get();return $e(K)<.001}function N(){return g}function A(){return p}function D(){return h}function U(){return Z(f)}function X(){return P(d)}function Z(K){return g=K,$}function P(K){return x=K,$}const $={direction:A,duration:N,velocity:D,seek:z,settled:w,useBaseFriction:X,useBaseDuration:U,useFriction:P,useDuration:Z};return $}function Lv(i,c,o,u,f){const d=f.measure(10),h=f.measure(50),p=ka(.1,.99);let g=!1;function x(){return!(g||!i.reachedAny(o.get())||!i.reachedAny(c.get()))}function v(w){if(!x())return;const N=i.reachedMin(c.get())?"min":"max",A=$e(i[N]-c.get()),D=o.get()-c.get(),U=p.constrain(A/h);o.subtract(D*U),!w&&$e(D)<d&&(o.set(i.constrain(o.get())),u.useDuration(25).useBaseFriction())}function y(w){g=!w}return{shouldConstrain:x,constrain:v,toggleActive:y}}function Bv(i,c,o,u,f){const d=ka(-c+i,0),h=y(),p=v(),g=z();function x(N,A){return As(N,A)<=1}function v(){const N=h[0],A=el(h),D=h.lastIndexOf(N),U=h.indexOf(A)+1;return ka(D,U)}function y(){return o.map((N,A)=>{const{min:D,max:U}=d,X=d.constrain(N),Z=!A,P=mu(o,A);return Z?U:P||x(D,X)?D:x(U,X)?U:X}).map(N=>parseFloat(N.toFixed(3)))}function z(){if(c<=i+f)return[d.max];if(u==="keepSnaps")return h;const{min:N,max:A}=p;return h.slice(N,A)}return{snapsContained:g,scrollContainLimit:p}}function qv(i,c,o){const u=c[0],f=o?u-i:el(c);return{limit:ka(f,u)}}function Yv(i,c,o,u){const d=c.min+.1,h=c.max+.1,{reachedMin:p,reachedMax:g}=ka(d,h);function x(z){return z===1?g(o.get()):z===-1?p(o.get()):!1}function v(z){if(!x(z))return;const w=i*(z*-1);u.forEach(N=>N.add(w))}return{loop:v}}function Gv(i){const{max:c,length:o}=i;function u(d){const h=d-c;return o?h/-o:0}return{get:u}}function $v(i,c,o,u,f){const{startEdge:d,endEdge:h}=i,{groupSlides:p}=f,g=y().map(c.measure),x=z(),v=w();function y(){return p(u).map(A=>el(A)[h]-A[0][d]).map($e)}function z(){return u.map(A=>o[d]-A[d]).map(A=>-$e(A))}function w(){return p(x).map(A=>A[0]).map((A,D)=>A+g[D])}return{snaps:x,snapsAligned:v}}function Qv(i,c,o,u,f,d){const{groupSlides:h}=f,{min:p,max:g}=u,x=v();function v(){const z=h(d),w=!i||c==="keepSnaps";return o.length===1?[d]:w?z:z.slice(p,g).map((N,A,D)=>{const U=!A,X=mu(D,A);if(U){const Z=el(D[0])+1;return Yh(Z)}if(X){const Z=Ys(d)-el(D)[0]+1;return Yh(Z,el(D)[0])}return N})}return{slideRegistry:x}}function Xv(i,c,o,u,f){const{reachedAny:d,removeOffset:h,constrain:p}=u;function g(N){return N.concat().sort((A,D)=>$e(A)-$e(D))[0]}function x(N){const A=i?h(N):p(N),D=c.map((X,Z)=>({diff:v(X-A,0),index:Z})).sort((X,Z)=>$e(X.diff)-$e(Z.diff)),{index:U}=D[0];return{index:U,distance:A}}function v(N,A){const D=[N,N+o,N-o];if(!i)return N;if(!A)return g(D);const U=D.filter(X=>du(X)===A);return U.length?g(U):el(D)-o}function y(N,A){const D=c[N]-f.get(),U=v(D,A);return{index:N,distance:U}}function z(N,A){const D=f.get()+N,{index:U,distance:X}=x(D),Z=!i&&d(D);if(!A||Z)return{index:U,distance:N};const P=c[U]-X,$=N+v(P,0);return{index:U,distance:$}}return{byDistance:z,byIndex:y,shortcut:v}}function Vv(i,c,o,u,f,d,h){function p(y){const z=y.distance,w=y.index!==c.get();d.add(z),z&&(u.duration()?i.start():(i.update(),i.render(1),i.update())),w&&(o.set(c.get()),c.set(y.index),h.emit("select"))}function g(y,z){const w=f.byDistance(y,z);p(w)}function x(y,z){const w=c.clone().set(y),N=f.byIndex(w.get(),z);p(N)}return{distance:g,index:x}}function Zv(i,c,o,u,f,d,h,p){const g={passive:!0,capture:!0};let x=0;function v(w){if(!p)return;function N(A){if(new Date().getTime()-x>10)return;h.emit("slideFocusStart"),i.scrollLeft=0;const X=o.findIndex(Z=>Z.includes(A));fu(X)&&(f.useDuration(0),u.index(X,0),h.emit("slideFocus"))}d.add(document,"keydown",y,!1),c.forEach((A,D)=>{d.add(A,"focus",U=>{(pc(p)||p(w,U))&&N(D)},g)})}function y(w){w.code==="Tab"&&(x=new Date().getTime())}return{init:v}}function ws(i){let c=i;function o(){return c}function u(g){c=h(g)}function f(g){c+=h(g)}function d(g){c-=h(g)}function h(g){return fu(g)?g:g.get()}return{get:o,set:u,add:f,subtract:d}}function _p(i,c){const o=i.scroll==="x"?h:p,u=c.style;let f=null,d=!1;function h(z){return`translate3d(${z}px,0px,0px)`}function p(z){return`translate3d(0px,${z}px,0px)`}function g(z){if(d)return;const w=Av(i.direction(z));w!==f&&(u.transform=o(w),f=w)}function x(z){d=!z}function v(){d||(u.transform="",c.getAttribute("style")||c.removeAttribute("style"))}return{clear:v,to:g,toggleActive:x}}function Kv(i,c,o,u,f,d,h,p,g){const v=Ds(f),y=Ds(f).reverse(),z=U().concat(X());function w(G,ee){return G.reduce((se,Q)=>se-f[Q],ee)}function N(G,ee){return G.reduce((se,Q)=>w(se,ee)>0?se.concat([Q]):se,[])}function A(G){return d.map((ee,se)=>({start:ee-u[se]+.5+G,end:ee+c-.5+G}))}function D(G,ee,se){const Q=A(ee);return G.map(ae=>{const ye=se?0:-o,Ge=se?o:0,Qe=se?"end":"start",ce=Q[ae][Qe];return{index:ae,loopPoint:ce,slideLocation:ws(-1),translate:_p(i,g[ae]),target:()=>p.get()>ce?ye:Ge}})}function U(){const G=h[0],ee=N(y,G);return D(ee,o,!1)}function X(){const G=c-h[0]-1,ee=N(v,G);return D(ee,-o,!0)}function Z(){return z.every(({index:G})=>{const ee=v.filter(se=>se!==G);return w(ee,c)<=.1})}function P(){z.forEach(G=>{const{target:ee,translate:se,slideLocation:Q}=G,ae=ee();ae!==Q.get()&&(se.to(ae),Q.set(ae))})}function $(){z.forEach(G=>G.translate.clear())}return{canLoop:Z,clear:$,loop:P,loopPoints:z}}function Jv(i,c,o){let u,f=!1;function d(g){if(!o)return;function x(v){for(const y of v)if(y.type==="childList"){g.reInit(),c.emit("slidesChanged");break}}u=new MutationObserver(v=>{f||(pc(o)||o(g,v))&&x(v)}),u.observe(i,{childList:!0})}function h(){u&&u.disconnect(),f=!0}return{init:d,destroy:h}}function Wv(i,c,o,u){const f={};let d=null,h=null,p,g=!1;function x(){p=new IntersectionObserver(N=>{g||(N.forEach(A=>{const D=c.indexOf(A.target);f[D]=A}),d=null,h=null,o.emit("slidesInView"))},{root:i.parentElement,threshold:u}),c.forEach(N=>p.observe(N))}function v(){p&&p.disconnect(),g=!0}function y(N){return Rs(f).reduce((A,D)=>{const U=parseInt(D),{isIntersecting:X}=f[U];return(N&&X||!N&&!X)&&A.push(U),A},[])}function z(N=!0){if(N&&d)return d;if(!N&&h)return h;const A=y(N);return N&&(d=A),N||(h=A),A}return{init:x,destroy:v,get:z}}function Fv(i,c,o,u,f,d){const{measureSize:h,startEdge:p,endEdge:g}=i,x=o[0]&&f,v=N(),y=A(),z=o.map(h),w=D();function N(){if(!x)return 0;const X=o[0];return $e(c[p]-X[p])}function A(){if(!x)return 0;const X=d.getComputedStyle(el(u));return parseFloat(X.getPropertyValue(`margin-${g}`))}function D(){return o.map((X,Z,P)=>{const $=!Z,K=mu(P,Z);return $?z[Z]+v:K?z[Z]+y:P[Z+1][p]-X[p]}).map($e)}return{slideSizes:z,slideSizesWithGaps:w,startGap:v,endGap:y}}function Iv(i,c,o,u,f,d,h,p,g){const{startEdge:x,endEdge:v,direction:y}=i,z=fu(o);function w(U,X){return Ds(U).filter(Z=>Z%X===0).map(Z=>U.slice(Z,Z+X))}function N(U){return U.length?Ds(U).reduce((X,Z,P)=>{const $=el(X)||0,K=$===0,G=Z===Ys(U),ee=f[x]-d[$][x],se=f[x]-d[Z][v],Q=!u&&K?y(h):0,ae=!u&&G?y(p):0,ye=$e(se-ae-(ee+Q));return P&&ye>c+g&&X.push(Z),G&&X.push(U.length),X},[]).map((X,Z,P)=>{const $=Math.max(P[Z-1]||0);return U.slice($,X)}):[]}function A(U){return z?w(U,o):N(U)}return{groupSlides:A}}function Pv(i,c,o,u,f,d,h){const{align:p,axis:g,direction:x,startIndex:v,loop:y,duration:z,dragFree:w,dragThreshold:N,inViewThreshold:A,slidesToScroll:D,skipSnaps:U,containScroll:X,watchResize:Z,watchSlides:P,watchDrag:$,watchFocus:K}=d,G=2,ee=Dv(),se=ee.measure(c),Q=o.map(ee.measure),ae=Mv(g,x),ye=ae.measureSize(se),Ge=Rv(ye),Qe=_v(p,ye),ce=!y&&!!X,k=y||!!X,{slideSizes:Y,slideSizesWithGaps:te,startGap:he,endGap:je}=Fv(ae,se,Q,o,k,f),S=Iv(ae,ye,D,y,se,Q,he,je,G),{snaps:L,snapsAligned:V}=$v(ae,Qe,se,Q,S),J=-el(L)+el(te),{snapsContained:re,scrollContainLimit:fe}=Bv(ye,J,V,X,G),oe=ce?re:V,{limit:ze}=qv(J,oe,y),Ee=Ap(Ys(oe),v,y),at=Ee.clone(),Me=Ds(o),I=({dragHandler:pl,scrollBody:kn,scrollBounds:On,options:{loop:al}})=>{al||On.constrain(pl.pointerDown()),kn.seek()},Te=({scrollBody:pl,translate:kn,location:On,offsetLocation:al,previousLocation:pt,scrollLooper:nl,slideLooper:xt,dragHandler:xc,animation:gc,eventHandler:Xs,scrollBounds:Ra,options:{loop:da}},ma)=>{const sl=pl.settled(),Ua=!Ra.shouldConstrain(),Ul=da?sl:sl&&Ua,Vs=Ul&&!xc.pointerDown();Vs&&gc.stop();const Zs=On.get()*ma+pt.get()*(1-ma);al.set(Zs),da&&(nl.loop(pl.direction()),xt.loop()),kn.to(al.get()),Vs&&Xs.emit("settle"),Ul||Xs.emit("scroll")},ke=Cv(u,f,()=>I(Mn),pl=>Te(Mn,pl)),tt=.68,yt=oe[Ee.get()],Jt=ws(yt),ll=ws(yt),Ht=ws(yt),Wt=ws(yt),hl=Hv(Jt,Ht,ll,Wt,z,tt),An=Xv(y,oe,J,ze,Wt),_n=Vv(ke,Ee,at,hl,An,Wt,h),dt=Gv(ze),$s=Us(),Qs=Wv(c,o,h,A),{slideRegistry:Cn}=Qv(ce,X,oe,fe,S,Me),Da=Zv(i,o,Cn,_n,hl,$s,h,K),Mn={ownerDocument:u,ownerWindow:f,eventHandler:h,containerRect:se,slideRects:Q,animation:ke,axis:ae,dragHandler:kv(ae,i,u,f,Wt,Ov(ae,f),Jt,ke,_n,hl,An,Ee,h,Ge,w,N,U,tt,$),eventStore:$s,percentOfView:Ge,index:Ee,indexPrevious:at,limit:ze,location:Jt,offsetLocation:Ht,previousLocation:ll,options:d,resizeHandler:Uv(c,h,f,o,ae,Z,ee),scrollBody:hl,scrollBounds:Lv(ze,Ht,Wt,hl,Ge),scrollLooper:Yv(J,ze,Ht,[Jt,Ht,ll,Wt]),scrollProgress:dt,scrollSnapList:oe.map(dt.get),scrollSnaps:oe,scrollTarget:An,scrollTo:_n,slideLooper:Kv(ae,ye,J,Y,te,L,oe,Ht,o),slideFocus:Da,slidesHandler:Jv(c,h,P),slidesInView:Qs,slideIndexes:Me,slideRegistry:Cn,slidesToScroll:S,target:Wt,translate:_p(ae,c)};return Mn}function ey(){let i={},c;function o(x){c=x}function u(x){return i[x]||[]}function f(x){return u(x).forEach(v=>v(c,x)),g}function d(x,v){return i[x]=u(x).concat([v]),g}function h(x,v){return i[x]=u(x).filter(y=>y!==v),g}function p(){i={}}const g={init:o,emit:f,off:h,on:d,clear:p};return g}const ty={align:"center",axis:"x",container:null,slides:null,containScroll:"trimSnaps",direction:"ltr",slidesToScroll:1,inViewThreshold:0,breakpoints:{},dragFree:!1,dragThreshold:10,loop:!1,skipSnaps:!1,duration:25,startIndex:0,active:!0,watchDrag:!0,watchResize:!0,watchSlides:!0,watchFocus:!0};function ly(i){function c(d,h){return Tp(d,h||{})}function o(d){const h=d.breakpoints||{},p=Rs(h).filter(g=>i.matchMedia(g).matches).map(g=>h[g]).reduce((g,x)=>c(g,x),{});return c(d,p)}function u(d){return d.map(h=>Rs(h.breakpoints||{})).reduce((h,p)=>h.concat(p),[]).map(i.matchMedia)}return{mergeOptions:c,optionsAtMedia:o,optionsMediaQueries:u}}function ay(i){let c=[];function o(d,h){return c=h.filter(({options:p})=>i.optionsAtMedia(p).active!==!1),c.forEach(p=>p.init(d,i)),h.reduce((p,g)=>Object.assign(p,{[g.name]:g}),{})}function u(){c=c.filter(d=>d.destroy())}return{init:o,destroy:u}}function uc(i,c,o){const u=i.ownerDocument,f=u.defaultView,d=ly(f),h=ay(d),p=Us(),g=ey(),{mergeOptions:x,optionsAtMedia:v,optionsMediaQueries:y}=d,{on:z,off:w,emit:N}=g,A=ae;let D=!1,U,X=x(ty,uc.globalOptions),Z=x(X),P=[],$,K,G;function ee(){const{container:Me,slides:I}=Z;K=(Zo(Me)?i.querySelector(Me):Me)||i.children[0];const ke=Zo(I)?K.querySelectorAll(I):I;G=[].slice.call(ke||K.children)}function se(Me){const I=Pv(i,K,G,u,f,Me,g);if(Me.loop&&!I.slideLooper.canLoop()){const Te=Object.assign({},Me,{loop:!1});return se(Te)}return I}function Q(Me,I){D||(X=x(X,Me),Z=v(X),P=I||P,ee(),U=se(Z),y([X,...P.map(({options:Te})=>Te)]).forEach(Te=>p.add(Te,"change",ae)),Z.active&&(U.translate.to(U.location.get()),U.animation.init(),U.slidesInView.init(),U.slideFocus.init(at),U.eventHandler.init(at),U.resizeHandler.init(at),U.slidesHandler.init(at),U.options.loop&&U.slideLooper.loop(),K.offsetParent&&G.length&&U.dragHandler.init(at),$=h.init(at,P)))}function ae(Me,I){const Te=S();ye(),Q(x({startIndex:Te},Me),I),g.emit("reInit")}function ye(){U.dragHandler.destroy(),U.eventStore.clear(),U.translate.clear(),U.slideLooper.clear(),U.resizeHandler.destroy(),U.slidesHandler.destroy(),U.slidesInView.destroy(),U.animation.destroy(),h.destroy(),p.clear()}function Ge(){D||(D=!0,p.clear(),ye(),g.emit("destroy"),g.clear())}function Qe(Me,I,Te){!Z.active||D||(U.scrollBody.useBaseFriction().useDuration(I===!0?0:Z.duration),U.scrollTo.index(Me,Te||0))}function ce(Me){const I=U.index.add(1).get();Qe(I,Me,-1)}function k(Me){const I=U.index.add(-1).get();Qe(I,Me,1)}function Y(){return U.index.add(1).get()!==S()}function te(){return U.index.add(-1).get()!==S()}function he(){return U.scrollSnapList}function je(){return U.scrollProgress.get(U.offsetLocation.get())}function S(){return U.index.get()}function L(){return U.indexPrevious.get()}function V(){return U.slidesInView.get()}function J(){return U.slidesInView.get(!1)}function re(){return $}function fe(){return U}function oe(){return i}function ze(){return K}function Ee(){return G}const at={canScrollNext:Y,canScrollPrev:te,containerNode:ze,internalEngine:fe,destroy:Ge,off:w,on:z,emit:N,plugins:re,previousScrollSnap:L,reInit:A,rootNode:oe,scrollNext:ce,scrollPrev:k,scrollProgress:je,scrollSnapList:he,scrollTo:Qe,selectedScrollSnap:S,slideNodes:Ee,slidesInView:V,slidesNotInView:J};return Q(c,o),setTimeout(()=>g.emit("init"),0),at}uc.globalOptions=void 0;function hu(i={},c=[]){const o=q.useRef(i),u=q.useRef(c),[f,d]=q.useState(),[h,p]=q.useState(),g=q.useCallback(()=>{f&&f.reInit(o.current,u.current)},[f]);return q.useEffect(()=>{uu(o.current,i)||(o.current=i,g())},[i,g]),q.useEffect(()=>{wv(u.current,c)||(u.current=c,g())},[c,g]),q.useEffect(()=>{if(Ev()&&h){uc.globalOptions=hu.globalOptions;const x=uc(h,o.current,u.current);return d(x),()=>x.destroy()}else d(void 0)},[h,d]),[p,f]}hu.globalOptions=void 0;const pu=4500;function xu({box:i,interval:c,advance:o}){const u=q.useRef(o);u.current=o,q.useEffect(()=>{const f=i.current;if(!f||c<=0||window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;let d=0,h=!0;const p=()=>{d+=1},g=()=>{d=Math.max(0,d-1)},x=window.setInterval(()=>{d>0||document.hidden||!h||u.current()||window.clearInterval(x)},c),v=new IntersectionObserver(([A])=>{h=A.isIntersecting},{threshold:.25});v.observe(f);const y=window.matchMedia("(hover: hover) and (pointer: fine)").matches;y&&(f.addEventListener("pointerenter",p),f.addEventListener("pointerleave",g));let z=!1;const w=A=>{const D=A.target;!(D instanceof Element)||!D.matches(":focus-visible")||z||(z=!0,p())},N=()=>{z&&(z=!1,g())};return f.addEventListener("focusin",w),f.addEventListener("focusout",N),f.addEventListener("pointerdown",p,!0),window.addEventListener("pointerup",g,!0),window.addEventListener("pointercancel",g,!0),()=>{window.clearInterval(x),v.disconnect(),y&&(f.removeEventListener("pointerenter",p),f.removeEventListener("pointerleave",g)),f.removeEventListener("focusin",w),f.removeEventListener("focusout",N),f.removeEventListener("pointerdown",p,!0),window.removeEventListener("pointerup",g,!0),window.removeEventListener("pointercancel",g,!0)}},[i,c])}function Cp(i,c=.85){if(!i)return!1;const o=i.scrollWidth-i.clientWidth;if(o<=8||i.scrollLeft>=o-8)return!1;const u=window.matchMedia("(prefers-reduced-motion: reduce)").matches;return i.scrollBy({left:i.clientWidth*c,behavior:u?"auto":"smooth"}),!0}function gu({label:i,children:c,align:o="start",loop:u=!1,dots:f=!0,arrows:d="header",gap:h=1,autoplay:p=pu,onSelect:g,onReady:x,className:v}){const y=p===!1?0:p,[z,w]=hu({align:o,loop:u,containScroll:"trimSnaps",watchDrag:!0}),[N,A]=q.useState([]),[D,U]=q.useState(0),[X,Z]=q.useState(!1),[P,$]=q.useState(!1),K=q.useRef(null),G=q.useId();q.useEffect(()=>{var Y;if(!w)return;const ce=()=>{U(w.selectedScrollSnap()),Z(w.canScrollPrev()),$(w.canScrollNext())},k=()=>{A(w.scrollSnapList()),ce()};return k(),w.on("select",ce),w.on("reInit",k),(Y=K.current)==null||Y.setAttribute("data-slider","on"),()=>{w.off("select",ce),w.off("reInit",k)}},[w]),q.useEffect(()=>{if(!w)return;const ce=K.current,k=()=>ce==null?void 0:ce.setAttribute("data-dragging","true"),Y=()=>ce==null?void 0:ce.removeAttribute("data-dragging");return w.on("pointerDown",k),w.on("pointerUp",Y),()=>{w.off("pointerDown",k),w.off("pointerUp",Y)}},[w]),xu({box:K,interval:y,advance:()=>!w||w.scrollSnapList().length<=1?!0:w.canScrollNext()?(w.scrollNext(),!0):!1});const ee=q.useCallback(ce=>w==null?void 0:w.scrollTo(ce),[w]),se=q.useRef(g);se.current=g;const Q=q.useRef(x);Q.current=x,q.useEffect(()=>{var k;if(!w)return;const ce=()=>{var Y;return(Y=se.current)==null?void 0:Y.call(se,w.selectedScrollSnap())};return ce(),w.on("select",ce),w.on("reInit",ce),(k=Q.current)==null||k.call(Q,{scrollTo:Y=>w.scrollTo(Y)}),()=>{w.off("select",ce),w.off("reInit",ce)}},[w]);const ae=q.useCallback(()=>w==null?void 0:w.scrollPrev(),[w]),ye=q.useCallback(()=>w==null?void 0:w.scrollNext(),[w]),Ge=N.length>1,Qe=f&&Ge&&N.length<=12;return n.jsxs("div",{className:v,children:[d==="header"&&Ge&&n.jsxs("div",{className:"mb-3 flex justify-end gap-2",children:[n.jsx(Gh,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx(Gh,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]}),n.jsxs("div",{className:"relative",children:[n.jsx("div",{ref:ce=>{K.current=ce,z(ce)},className:"slider-viewport",role:"group","aria-roledescription":"캐러셀","aria-label":i,tabIndex:0,onKeyDown:ce=>{ce.key==="ArrowLeft"&&(ce.preventDefault(),ae()),ce.key==="ArrowRight"&&(ce.preventDefault(),ye())},children:n.jsx("div",{className:"slider-track",style:{gap:`${h}rem`},id:G,children:c})}),d==="overlay"&&Ge&&n.jsxs(n.Fragment,{children:[n.jsx($h,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx($h,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]})]}),Ge&&n.jsxs("div",{className:"mt-4 flex items-center justify-center gap-3",children:[Qe&&n.jsx("ul",{className:"flex items-center gap-1.5",children:N.map((ce,k)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>ee(k),"aria-label":`${k+1}번째로`,"aria-current":k===D,"aria-controls":G,className:`h-1.5 rounded-full transition-all ${k===D?"w-6 bg-current opacity-70":"w-1.5 bg-current opacity-20 hover:opacity-45"}`})},k))}),n.jsxs("span",{className:"text-current/55 text-[length:var(--fs-xs)] tabular-nums",children:[D+1," / ",N.length]})]})]})}function Mp({basis:i,children:c}){return n.jsx("div",{className:`min-w-0 shrink-0 grow-0 ${i}`,"aria-roledescription":"슬라이드",children:c})}function Gh({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:"tap border-line flex items-center justify-center rounded-full border transition-opacity hover:opacity-70 disabled:opacity-25",children:n.jsx(f,{className:"size-5"})})}function $h({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:`tap absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:opacity-0 sm:flex ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(f,{className:"size-5"})})}function bu({children:i,className:c="",as:o="div"}){return n.jsx(o,{className:`panel ${c}`,children:i})}function vu({children:i}){return n.jsx("span",{className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]",children:i})}function kp({label:i,value:c,note:o}){return n.jsxs("div",{className:"divide-line flex flex-col gap-1 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] font-medium sm:w-40",children:i}),n.jsxs("dd",{className:"flex-1 text-[length:var(--fs-sm)] font-semibold sm:text-right",children:[c,o&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:o})]})]})}function Qh({href:i,children:c,variant:o="solid",external:u,icon:f}){const d=o==="solid";return n.jsxs("a",{href:i,...u?{target:"_blank",rel:"noopener noreferrer"}:{},className:`tap inline-flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-semibold transition-opacity hover:opacity-85 ${d?"":"border-line tpl-border border bg-current/5"}`,style:d?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"}:void 0,children:[f,n.jsx("span",{children:c}),u&&n.jsx(Sn,{className:"size-4 shrink-0"})]})}function ny(){const i=le(),{narrative:c,place:o}=i,u=H0(i,"intro"),f=u.length>0?u:c.about;if(f.length===0)return null;const d=lu(i).find(p=>!p.isPrimary),h=c.heroSubline??`${o.name} 소개`;return n.jsx(vt,{id:"about",bare:!0,title:h,children:n.jsxs("div",{className:"grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14",children:[d&&n.jsxs("figure",{className:"lg:col-span-7",children:[n.jsx("div",{className:"tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2",children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover"})}),d.caption&&n.jsx("figcaption",{className:"text-muted mt-2 text-[length:var(--fs-xs)]",children:d.caption})]}),n.jsxs("div",{className:d?"lg:col-span-5":"lg:col-span-12",children:[n.jsxs("h2",{id:"about-heading",className:"h2 mb-6 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:h})]}),n.jsx("div",{className:"measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]",children:f.map((p,g)=>n.jsx("p",{children:p},g))})]})]})})}const Xh=new Set(["체크인 시간","체크아웃 시간","취소·환불 규정","취사 가능","반려동물 동반","흡연 가능","인원 추가 요금"]);function sy(){const i=le(),c=dc(i),o=i.facts.filter(d=>d.scope==="place"&&!c.some(h=>h.label===d.label)).length;if(c.length===0)return null;const u=c.filter(d=>Xh.has(d.label)),f=c.filter(d=>!Xh.has(d.label));return n.jsx(vt,{id:"info",tone:"alt",title:"이용안내 및 예약",lead:"방문 전 확인이 필요한 운영 규정과 시설 안내입니다.",footnote:n.jsxs("span",{className:"flex flex-col gap-1 sm:flex-row sm:justify-between",children:[n.jsx("span",{children:o>0?`확인 중인 항목 ${o}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`:"모든 항목이 사업자 확인을 거쳤습니다."}),n.jsxs("span",{className:"font-medium",children:[nu(i.site.updatedAt)," 기준"]})]}),children:n.jsxs("div",{className:"space-y-8",children:[u.length>0&&n.jsx(Vh,{title:"예약 전 확인",rows:u,emphasis:!0}),f.length>0&&n.jsx(Vh,{title:"시설 · 편의",rows:f}),n.jsx(iy,{})]})})}function Vh({title:i,rows:c,emphasis:o}){return n.jsxs("section",{children:[n.jsxs("h3",{className:"border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[o&&n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0",style:{backgroundColor:"var(--color-accent)"}}),n.jsx("span",{children:i})]}),n.jsx("dl",{className:"divide-line divide-y",children:c.map(u=>n.jsxs("div",{className:"flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6",children:[n.jsx("dt",{className:"text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0",children:u.label}),n.jsxs("dd",{className:"measure text-[length:var(--fs-sm)] font-semibold",children:[u.value,u.note&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:u.note})]})]},u.label))})]})}function iy(){const i=le(),c=Ze(i),o=i.place.phone;return!o&&c.length===0?null:n.jsxs("div",{className:"border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("p",{className:"text-[length:var(--fs-sm)] font-semibold",children:[i.place.name," 예약은 아래로 받습니다."]}),n.jsxs("div",{className:"flex flex-wrap gap-2",children:[o&&n.jsxs("a",{href:`tel:${o}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:o})]}),c.map(u=>n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)},u.url))]})]})}function cy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:n.jsx("ul",{className:"mt-2 flex flex-col gap-14 lg:gap-20",children:o.map((f,d)=>n.jsxs("li",{className:"grid items-center gap-7 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-2":""}`,children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 rounded-lg overflow-hidden",children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),f.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:f.images.slice(1,4).map(h=>n.jsx("li",{className:"relative aspect-16/10 rounded-md overflow-hidden",children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]}),n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-1":""}`,children:[n.jsx("h3",{className:"h2",children:f.name}),f.intro&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed opacity-80",children:f.intro}),f.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:h.label}),n.jsx("span",{className:"font-medium",children:h.value})]})},h.label))}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,f.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),f.rows.length>0&&n.jsxs("details",{className:"group border-line mt-4 border-t pt-2",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[f.name," 구성 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right font-semibold",children:h.value})]},h.label))})]})]})]},f.unitId))})})}function ry(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0],[f,d]=q.useState(0);return o.length===0?null:n.jsxs(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:[n.jsx("div",{className:"border-line mb-6 flex gap-1 border-b",role:"tablist","aria-label":c.label,children:o.map((h,p)=>n.jsx("button",{type:"button",role:"tab","aria-selected":p===f,"aria-controls":`unit-panel-${h.slug}`,onClick:()=>d(p),className:`tap -mb-px border-b-2 px-5 text-[length:var(--fs-sm)] font-bold transition-colors ${p===f?"opacity-100":"border-transparent opacity-50 hover:opacity-80"}`,style:p===f?{borderColor:"var(--color-brand)"}:void 0,children:h.name},h.unitId))}),o.map((h,p)=>n.jsxs("div",{id:`unit-panel-${h.slug}`,role:"tabpanel",hidden:p!==f,className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"lg:col-span-7",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden rounded-lg",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),h.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:h.images.slice(1,4).map(g=>n.jsx("li",{className:"relative aspect-16/10 overflow-hidden rounded-md",children:n.jsx("img",{src:g.url,alt:g.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},g.mediaId))})]}),n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h3",{className:"h3",children:h.name}),h.intro&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:h.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),h.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-4 divide-y border-t",children:h.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))}),n.jsxs("div",{className:"mt-5 flex gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,h.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]})]})]},h.unitId))]})}function oy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];if(o.length===0)return null;const f=o.length===1?"grid-cols-1 max-w-2xl mx-auto":o.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",d=Kt(i,"info")?[]:dc(i);return n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"calc(var(--section-space) * 1.4)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(iu,{id:"units",title:Ye(i,c.path,`${c.label} 안내`)}),n.jsx("ul",{className:`grid gap-x-8 gap-y-14 lg:gap-x-12 ${f}`,children:o.map(h=>n.jsxs("li",{className:"flex min-w-0 flex-col",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-4/3 w-full overflow-hidden",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})}),n.jsx("h3",{className:"serif mt-6",style:{fontSize:"var(--fs-h3)",fontWeight:300,letterSpacing:"0.04em"},children:h.name}),h.intro&&n.jsx("p",{className:"text-muted mt-3 line-clamp-2 text-[length:var(--fs-sm)] font-light leading-loose",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"text-muted mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)] font-light",children:h.chips.map(p=>n.jsxs("li",{children:[p.label," ",p.value]},p.label))}),(h.rows.length>0||h.images.length>1)&&n.jsxs("details",{className:"border-line mt-5 border-t",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center text-[length:var(--fs-sm)] font-light opacity-70 marker:content-none [&::-webkit-details-marker]:hidden",children:"자세히 보기"}),n.jsxs("div",{className:"space-y-6 pb-2",children:[h.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:h.rows.map(p=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:p.label}),n.jsx("dd",{className:"text-right",children:p.value})]},p.label))}),h.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-3",children:h.images.slice(1).map(p=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})},p.mediaId))})]})]})]},h.unitId))}),d.length>0&&n.jsx("dl",{className:"divide-line border-line mx-auto mt-16 max-w-2xl divide-y border-t",children:d.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-3 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right",children:h.value})]},h.label))}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-14 flex flex-wrap items-center justify-center gap-4",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-opacity hover:opacity-70",children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tabular-nums tracking-[0.08em] transition-opacity hover:opacity-70",children:i.place.phone})]})]})})}function uy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsxs("section",{id:"units","data-oasi":!0,"aria-labelledby":"units-heading",className:"pt-[var(--oasi-gap)]",children:[n.jsx(cu,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-20 lg:space-y-28",children:o.map(f=>n.jsxs("li",{children:[f.images[0]&&n.jsxs("figure",{className:"relative mx-auto w-full max-w-[34rem]",children:[n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"block h-[clamp(15rem,24vw,21rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:f.name})]}),n.jsx("h3",{className:"mt-8 text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:f.name}),f.chips.length>0&&n.jsx("p",{className:"text-muted mt-2 text-[length:var(--fs-xs)] leading-[1.9]",children:f.chips.map(d=>`${d.label} ${d.value}`).join(" · ")}),f.intro&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-xs)] leading-[1.9]",children:f.intro}),f.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-6 divide-y border-t text-[length:var(--fs-xs)]",children:f.rows.map(d=>n.jsxs("div",{className:"flex items-baseline justify-between gap-6 py-2.5",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right",children:d.value})]},d.label))}),f.images.length>1&&n.jsx("ul",{className:"mx-auto mt-6 grid w-full max-w-[34rem] grid-cols-2 gap-2",children:f.images.slice(1).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",className:"block aspect-[4/3] w-full object-cover"})},d.mediaId))}),(u||i.place.phone)&&n.jsxs("p",{className:"mt-6 flex flex-wrap items-center gap-x-8 text-[length:var(--fs-sm)]",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap text-muted inline-flex items-center underline underline-offset-[6px]",children:i.place.phone})]})]},f.unitId))})]})}function fy(){const i=le(),c=ft(i),o=fl(i);return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"w-full",style:{paddingBlock:"var(--section-space)",backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ru,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-16 sm:space-y-24",children:o.map(u=>n.jsxs("li",{className:"grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"min-w-0 lg:col-span-7",children:[u.images[0]&&n.jsx("img",{src:u.images[0].url,alt:u.images[0].alt,loading:"lazy",decoding:"async",width:u.images[0].width,height:u.images[0].height,className:"block h-auto w-full"}),u.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-2 gap-2",children:u.images.slice(1).map(f=>n.jsx("li",{children:n.jsx("img",{src:f.url,alt:f.alt,loading:"lazy",decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})]}),n.jsxs("div",{className:"min-w-0 lg:col-span-5",children:[n.jsx("h3",{className:"serif min-w-0 break-keep font-extrabold",style:{fontSize:"var(--fs-h3)",letterSpacing:"0.02em",lineHeight:1.35},children:u.name}),u.chips.length>0&&n.jsx("dl",{className:"mt-4 space-y-1.5",children:u.chips.map(f=>n.jsxs("div",{className:"flex gap-3 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted w-20 shrink-0",children:f.label}),n.jsx("dd",{className:"min-w-0 font-medium",children:f.value})]},f.label))}),u.intro&&n.jsx("p",{className:"text-muted measure mt-5 text-[length:var(--fs-sm)]",style:{lineHeight:1.8},children:u.intro})]})]},u.unitId))})]})})}function dy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading","data-pastel":"own",className:"w-full",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ou,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:gap-8",children:o.map((f,d)=>n.jsxs("li",{className:"flex flex-col overflow-hidden p-4 sm:p-5",style:{backgroundColor:d%2===0?"var(--pastel-1)":"var(--pastel-2)",borderRadius:"calc(var(--tpl-radius, 0.75rem) * 1.35)"},children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",style:{borderRadius:"var(--tpl-radius, 0.75rem)"},children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 px-1 pt-4",children:[n.jsx("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:n.jsx("h3",{className:"h3",style:{fontWeight:800},children:f.name})}),f.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsxs("li",{className:"inline-flex items-center gap-1 rounded-full px-3 py-1 text-[length:var(--fs-xs)]",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 70%, transparent)"},children:[n.jsx("span",{className:"opacity-60",children:h.label}),n.jsx("span",{className:"font-bold",children:h.value})]},h.label))}),f.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:f.intro})]}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-4 flex gap-2 px-1",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-full text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(u)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap flex items-center justify-center gap-1.5 rounded-full px-5 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 75%, transparent)"},children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),(f.rows.length>0||f.images.length>1)&&n.jsxs("details",{className:"group mt-3 px-1",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsxs("span",{children:[f.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsxs("div",{className:"space-y-4 pb-1",children:[f.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"shrink-0 opacity-60",children:h.label}),n.jsx("dd",{className:"text-right font-bold",children:h.value})]},h.label))}),f.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-2",children:f.images.slice(1).map(h=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",style:{borderRadius:"calc(var(--tpl-radius, 0.75rem) * 0.7)"},children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]})]})]},f.unitId))})]})})}function my(){const i=le(),c=fl(i),o=ft(i),u=Ze(i)[0];return c.length===0?null:n.jsxs("section",{id:"units","aria-labelledby":"units-heading",className:"border-line w-full border-b",style:{backgroundColor:"var(--tpl-surface)",backgroundImage:Os,paddingBlock:"var(--section-space)"},children:[n.jsx("div",{className:"shell",children:n.jsx(au,{id:"units",title:Ye(i,o.path,`${o.label} ${c.length}개 안내`)})}),c.map((f,d)=>n.jsx(hy,{unit:f,no:String(d+1).padStart(2,"0"),dark:d%2===1,booking:u,phone:i.place.phone},f.unitId))]})}function hy({unit:i,no:c,dark:o,booking:u,phone:f}){return n.jsx("article",{className:"w-full",style:o?{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os}:void 0,children:n.jsxs("div",{className:"shell grid grid-cols-12 gap-y-6 py-10 xl:gap-x-10 xl:py-16",children:[n.jsxs("div",{className:"col-span-12 xl:col-span-5 xl:pr-8",children:[n.jsxs("div",{className:"relative",children:[n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute -top-1 left-0 select-none leading-none tabular-nums",style:{fontSize:"clamp(3rem, 8vw, 6rem)",fontWeight:800,opacity:.1},children:c}),n.jsx("h3",{className:"serif relative pt-7 xl:pt-12",style:{fontSize:"clamp(1.5rem, 3vw, 2.25rem)",fontWeight:800,lineHeight:1.1,letterSpacing:"-0.02em"},children:i.name})]}),i.intro&&n.jsx("p",{className:"measure mt-4 font-serif text-[length:var(--fs-body)] leading-relaxed opacity-85",children:i.intro}),i.rows.length>0&&n.jsx("dl",{className:"border-line mt-6 border-t",children:i.rows.map(d=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-6 border-b py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right font-semibold",children:d.value})]},d.label))}),(u||f)&&n.jsxs("div",{className:"mt-6 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(u)}),f&&n.jsx("a",{href:`tel:${f}`,className:"tap inline-flex items-center underline underline-offset-4",children:f})]})]}),n.jsxs("div",{className:"col-span-12 xl:col-span-7 xl:-ml-10",children:[i.images[0]&&n.jsx("figure",{className:"w-full xl:w-[64%]",children:n.jsx("img",{src:i.images[0].url,alt:i.images[0].alt,loading:"lazy",decoding:"async",width:i.images[0].width,height:i.images[0].height,className:"w-full object-cover",style:{aspectRatio:"4 / 5"}})}),i.images[1]&&n.jsx("figure",{className:"mt-3 w-full xl:-mt-24 xl:ml-auto xl:w-[50%]",children:n.jsx("img",{src:i.images[1].url,alt:i.images[1].alt,loading:"lazy",decoding:"async",width:i.images[1].width,height:i.images[1].height,className:"w-full object-cover",style:{aspectRatio:"4 / 3"}})})]}),i.images.length>2&&n.jsx("ul",{className:"col-span-12 grid grid-cols-3 gap-2 xl:grid-cols-6",children:i.images.slice(2).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"w-full object-cover",style:{aspectRatio:"3 / 2"}})},d.mediaId))})]})})}function Lo(){var h;const i=le(),c=ft(i),o=hc();if(o==="reservation")return n.jsx(oy,{});if(o==="oasi")return n.jsx(uy,{});if(o==="studio")return n.jsx(fy,{});if(o==="pastel")return n.jsx(dy,{});if(o==="editorial")return n.jsx(my,{});const u=(h=i.theme.sections.find(p=>p.id===c.path))==null?void 0:h.variantId;if(u==="rooms.bands")return n.jsx(cy,{});if(u==="rooms.tabs")return n.jsx(ry,{});const f=fl(i);if(f.length===0)return null;const d=f.length===1?"grid-cols-1":f.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 xl:grid-cols-3";return n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} ${f.length}개 안내`),children:n.jsx("ul",{className:`grid gap-5 lg:gap-7 ${d}`,children:f.map(p=>n.jsxs(bu,{as:"li",className:"flex flex-col overflow-hidden",children:[n.jsxs("div",{className:"flex flex-1 flex-col",children:[p.images.length>0&&n.jsx(py,{images:p.images,name:p.name}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 p-5",children:[n.jsx("h3",{className:"h3",children:p.name}),p.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:p.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),p.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:p.intro})]})]}),p.rows.length>0&&n.jsxs("details",{className:"group border-line border-t",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[p.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("div",{className:"space-y-5 px-5 pb-5",children:p.rows.length>0&&n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:p.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))})})]})]},p.unitId))})})}function py({images:i,name:c}){const o=q.useRef(null),[u,f]=q.useState(0),d=q.useCallback(()=>{const p=o.current;!p||p.clientWidth===0||f(Math.round(p.scrollLeft/p.clientWidth))},[]),h=q.useCallback(p=>{const g=o.current;if(!g)return;const x=window.matchMedia("(prefers-reduced-motion: reduce)").matches;g.scrollBy({left:p*g.clientWidth,behavior:x?"auto":"smooth"})},[]);return n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:o,onScroll:d,"aria-label":`${c} 사진`,className:"flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",children:i.map(p=>n.jsx("li",{className:"w-full shrink-0 snap-center",children:n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})})},p.mediaId))}),i.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Zh,{dir:"prev",show:u>0,onClick:()=>h(-1)}),n.jsx(Zh,{dir:"next",show:u<i.length-1,onClick:()=>h(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white",children:[Math.min(u+1,i.length)," / ",i.length]})]})]})}function Zh({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-4"})})}function xy(){const i=le(),c=Ze(i),o=q0(i),u=i.place.phone;return c.length===0&&o.length===0&&!u?null:n.jsx(vt,{id:"booking",tone:"alt",title:Ye(i,"booking","예약 안내"),lead:`${i.place.name} 예약은 아래 경로로 받습니다.`,children:n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[o.length>0&&n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5 lg:col-span-7",children:o.map(f=>n.jsx(kp,{label:f.label,value:f.value},f.label))}),n.jsx("div",{className:o.length>0?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:o.length>0?"flex flex-col gap-2.5 sm:flex-row sm:flex-wrap lg:flex-col":"flex flex-col items-start gap-2.5 sm:flex-row sm:flex-wrap",children:[u&&n.jsxs(Qh,{href:`tel:${u}`,icon:n.jsx(_t,{className:"size-4"}),children:["전화 예약 ",u]}),c.map(f=>n.jsx(Qh,{href:f.url,variant:"outline",external:!0,children:Sp(f)},f.url))]})})]})})}const Kh=["일","월","화","수","목","금","토"],Jh=2;function Wh(i){return`${i.getFullYear()}-${String(i.getMonth()+1).padStart(2,"0")}-${String(i.getDate()).padStart(2,"0")}`}function gy(i,c,o){const u=new Date(i,c,1),f=new Date(i,c+1,0).getDate(),d=Wh(o),h=Array.from({length:u.getDay()},()=>null);for(let p=1;p<=f;p+=1){const g=new Date(i,c,p),x=Wh(g);h.push({iso:x,day:p,weekday:g.getDay(),isWeekend:g.getDay()===0||g.getDay()===6,past:x<d})}return h}function by(i){const c=/(\d{1,2})\s*:\s*(\d{2})/.exec(i??"");if(!c)return[];const o=Number(c[1]),u=c[2];return Number.isFinite(o)?Array.from({length:5},(f,d)=>o+d).filter(f=>f<=23).map(f=>`${String(f).padStart(2,"0")}:${u}`):[]}function vy(i){return eu(i.units).map(c=>{var u;const o=f=>{var h;const d=Number((h=At(c.facts,f))==null?void 0:h.replace(/[^0-9]/g,""));return Number.isFinite(d)&&d>0?d:void 0};return{unitId:c.unitId,name:c.name,weekdayPrice:(u=yp(c))==null?void 0:u.price,weekendPrice:o("weekend_price"),maxCapacity:o("max_capacity")}})}function yy(){var se;const i=le(),c=q.useMemo(()=>vy(i),[i]),o=q.useMemo(()=>{var Q;return((Q=Bs(i.facts).find(ae=>ae.key==="check_in_time"))==null?void 0:Q.value)??void 0},[i]),u=q.useMemo(()=>by(o),[o]),[f,d]=q.useState(null),[h,p]=q.useState(0);q.useEffect(()=>d(new Date),[]);const g=q.useMemo(()=>f?new Date(f.getFullYear(),f.getMonth()+h,1):null,[f,h]),x=q.useMemo(()=>f&&g?gy(g.getFullYear(),g.getMonth(),f):[],[f,g]),[v,y]=q.useState(null),[z,w]=q.useState(null),[N,A]=q.useState(((se=c[0])==null?void 0:se.unitId)??null),[D,U]=q.useState(2),[X,Z]=q.useState(!1),P=x.find(Q=>Q!==null&&Q.iso===v)??null,$=c.find(Q=>Q.unitId===N)??null,K=($==null?void 0:$.maxCapacity)??8,G=P&&$?P.isWeekend?$.weekendPrice??$.weekdayPrice:$.weekdayPrice:void 0,ee=!!(v&&$&&(u.length===0||z));return q.useEffect(()=>{U(Q=>Math.min(Q,($==null?void 0:$.maxCapacity)??8))},[$]),c.length===0?null:n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("div",{className:"border-b border-black/8 px-4 py-3 sm:px-5",children:n.jsxs("p",{className:"flex items-center gap-2 text-xs font-bold",children:[n.jsx(Nb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"날짜 · 시간 선택"})]})}),f===null||g===null?n.jsx("p",{className:"px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5",children:"날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 확인해 주세요."}):X?n.jsx(jy,{payload:i,onReset:()=>Z(!1),summary:[P?`${g.getMonth()+1}월 ${P.day}일(${Kh[P.weekday]})`:null,z?`도착 ${z}`:null,($==null?void 0:$.name)??null,`${D}명`].filter(Q=>!!Q).join(" · ")}):n.jsxs("div",{className:"space-y-5 p-4 sm:p-5",children:[n.jsxs("div",{children:[n.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"날짜"}),n.jsxs("div",{className:"flex items-center gap-1",children:[n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.max(0,Q-1)),disabled:h===0,"aria-label":"이전 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(oa,{className:"size-3.5"})}),n.jsxs("span",{className:"w-24 text-center text-xs font-bold",children:[g.getFullYear(),"년 ",g.getMonth()+1,"월"]}),n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.min(Jh,Q+1)),disabled:h>=Jh,"aria-label":"다음 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(ua,{className:"size-3.5"})})]})]}),n.jsx("div",{className:"grid grid-cols-7 gap-1 border-b border-black/8 pb-1.5",children:Kh.map((Q,ae)=>n.jsx("span",{className:"text-center text-[10px] font-semibold",style:{opacity:ae===0||ae===6?.75:.45},children:Q},Q))}),n.jsx("div",{className:"mt-1.5 grid grid-cols-7 gap-1",children:x.map((Q,ae)=>Q===null?n.jsx("span",{"aria-hidden":!0},`pad-${ae}`):n.jsx("button",{type:"button",disabled:Q.past,onClick:()=>y(Q.iso),"aria-pressed":Q.iso===v,"aria-label":`${g.getMonth()+1}월 ${Q.day}일`,className:"flex h-9 items-center justify-center rounded-lg border text-xs transition-colors disabled:cursor-not-allowed",style:{borderColor:Q.iso===v?"var(--color-brand)":"transparent",backgroundColor:Q.iso===v?"var(--color-brand)":"var(--color-surface-alt)",color:Q.iso===v?"#fff":void 0,opacity:Q.past?.25:1,fontWeight:Q.iso===v?700:400},children:Q.day},Q.iso))}),(P==null?void 0:P.isWeekend)&&n.jsx("p",{className:"mt-2 text-[11px] opacity-55",children:"주말 요금이 적용되는 날짜입니다."})]}),u.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"mb-2 flex items-center gap-1.5 text-[11px] font-semibold opacity-55",children:[n.jsx(Cb,{className:"size-3"}),n.jsxs("span",{children:["도착 예정 시간 (체크인 ",o," 이후)"]})]}),n.jsx("ul",{className:"flex flex-wrap gap-1.5",children:u.map(Q=>{const ae=Q===z;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>w(Q),"aria-pressed":ae,className:"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:ae?"var(--color-brand)":"var(--color-surface-alt)",color:ae?"#fff":void 0},children:Q})},Q)})})]}),n.jsxs("div",{children:[n.jsx("p",{className:"mb-2 text-[11px] font-semibold opacity-55",children:"객실"}),n.jsx("ul",{className:"grid gap-1.5 sm:grid-cols-2",children:c.map(Q=>{const ae=Q.unitId===N;return n.jsx("li",{children:n.jsxs("button",{type:"button",onClick:()=>A(Q.unitId),"aria-pressed":ae,className:"flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left text-xs transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{className:"font-semibold",children:Q.name}),Q.maxCapacity&&n.jsxs("span",{className:"shrink-0 opacity-55",children:["최대 ",Q.maxCapacity,"명"]})]})},Q.unitId)})})]}),n.jsxs("div",{className:"flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"인원"}),n.jsxs("div",{className:"flex items-center gap-3",children:[n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.max(1,Q-1)),"aria-label":"인원 줄이기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Gb,{className:"size-3.5"})}),n.jsxs("span",{className:"w-10 text-center text-sm font-bold",children:[D,"명"]}),n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.min(K,Q+1)),"aria-label":"인원 늘리기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Kb,{className:"size-3.5"})})]})]}),n.jsxs("div",{className:"rounded-xl border border-black/8 p-3",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[n.jsx("span",{className:"text-xs opacity-60",children:P?`${g.getMonth()+1}월 ${P.day}일 · ${($==null?void 0:$.name)??""} · ${D}명`:"날짜를 골라 주세요"}),G!=null&&n.jsxs("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:[G.toLocaleString("ko-KR"),"원"]})]}),G!=null&&n.jsx("p",{className:"mt-1 text-[11px] opacity-50",children:"1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다."})]}),n.jsx("button",{type:"button",disabled:!ee,onClick:()=>Z(!0),className:"w-full rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40",style:{backgroundColor:"var(--color-brand)"},children:"예약 요청 확인하기"})]})]})}function jy({payload:i,summary:c,onReset:o}){const u=i.place.phone;return n.jsxs("div",{className:"space-y-4 p-4 sm:p-5",children:[n.jsxs("div",{className:"flex items-start gap-2.5",children:[n.jsx("span",{className:"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-white",style:{backgroundColor:"var(--color-brand)"},children:n.jsx(dp,{className:"size-4"})}),n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"text-sm font-bold",children:"예약 내용 확인"}),n.jsx("p",{className:"mt-0.5 text-xs leading-relaxed opacity-70",children:c})]})]}),n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row",children:[u&&n.jsxs("a",{href:`tel:${u}`,className:"flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(_t,{className:"size-4"}),n.jsxs("span",{children:["전화로 예약하기 ",u]})]}),n.jsxs("button",{type:"button",onClick:o,className:"flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",children:[n.jsx(Wb,{className:"size-3.5"}),n.jsx("span",{children:"다시 고르기"})]})]})]})}function Fh(){const i=le(),c=W0(i);if(!c)return null;const{offers:o,notices:u,links:f,contacts:d,phone:h}=c;return n.jsx("section",{id:"booking","aria-labelledby":"booking-heading",className:"w-full border-b border-black/8 py-16 sm:py-24",style:{backgroundColor:"var(--color-surface-alt)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("p",{className:"mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50",children:[n.jsx(yb,{className:"size-4"}),n.jsx("span",{children:"Reservation"})]}),n.jsx("h2",{id:"booking-heading",className:"serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl",children:Ye(i,"booking","예약 안내")}),n.jsx("p",{className:"mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm",children:"빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만 안내합니다."}),n.jsxs("div",{className:"grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6",children:[o.length>0&&n.jsxs("div",{className:"overflow-hidden rounded-2xl border border-black/8 lg:col-span-3",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(bb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"객실별 요금 · 인원"})]}),n.jsx("ul",{className:"divide-y divide-black/5",children:o.map(p=>n.jsxs("li",{className:"p-4 sm:p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:[n.jsx("h3",{className:"serif text-base font-bold sm:text-lg",children:p.name}),p.baseRateText&&n.jsx("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:p.baseRateText})]}),p.capacityText&&n.jsx("p",{className:"mt-1 text-xs opacity-60",children:p.capacityText}),p.rateRows.length>0&&n.jsx("dl",{className:"mt-3 flex flex-wrap gap-x-4 gap-y-1.5",children:p.rateRows.map(g=>n.jsxs("div",{className:"flex items-baseline gap-1.5 text-xs",children:[n.jsx("dt",{className:"opacity-50",children:g.label}),n.jsx("dd",{className:"font-semibold",children:g.value})]},g.label))}),n.jsx("a",{href:p.href,className:"mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70",children:n.jsxs("span",{children:[p.name," 사진 · 상세 보기"]})})]},p.unitId))})]}),n.jsxs("div",{className:"flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("p",{className:"text-xs font-bold",children:"예약 창구"}),h&&n.jsxs("a",{href:`tel:${h}`,className:"flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(_t,{className:"size-4"}),n.jsxs("span",{children:["전화 예약 ",h]})]}),f.map(p=>n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{children:Sp(p)}),n.jsx(Sn,{className:"size-3.5 shrink-0"})]},p.url)),d.length>0&&n.jsxs("div",{className:"mt-1 border-t border-black/8 pt-3",children:[n.jsx("p",{className:"mb-2 text-xs opacity-50",children:"문의"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:d.map(p=>n.jsx("li",{children:n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5",children:[n.jsx("span",{children:ml(p)}),n.jsx(Sn,{className:"size-3"})]})},p.url))})]}),f.length===0&&n.jsx("p",{className:"text-xs leading-relaxed opacity-55",children:"온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요."})]})]}),n.jsx(yy,{}),u.length>0&&n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(Ib,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"예약 전 확인"})]}),n.jsx("dl",{className:"grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0",children:u.map(p=>n.jsxs("div",{className:"flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5",children:[n.jsx("dt",{className:"shrink-0 text-xs opacity-55",children:p.label}),n.jsx("dd",{className:"text-xs font-semibold sm:max-w-[60%] sm:text-right",children:p.value})]},p.label))})]})]})})}function Ny(){const i=le(),c=G0(i);return c.length===0?null:n.jsx(vt,{id:"space",title:Ye(i,"space","공간 안내"),lead:"좌석과 이용 환경 안내입니다.",children:n.jsx("dl",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4",children:c.map(o=>n.jsxs(bu,{className:"p-4 sm:p-5",children:[n.jsx("dt",{className:"text-muted mb-1 text-[length:var(--fs-xs)] font-medium",children:o.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:o.value})]},o.label))})})}function Sy(){const i=le(),{place:c}=i,o=wn(i);if(!c.phone&&!c.email&&o.length===0)return null;const u=[...c.phone?[{key:"phone",icon:_t,label:"전화",value:c.phone,href:`tel:${c.phone}`}]:[],...c.email?[{key:"email",icon:mp,label:"이메일",value:c.email,href:`mailto:${c.email}`}]:[],...o.map(f=>({key:f.url,icon:Io,label:ml(f),value:"바로 가기",href:f.url,external:!0}))];return n.jsx(vt,{id:"inquiry",title:Ye(i,"inquiry","문의 안내"),lead:`궁금한 점은 아래로 연락 주시면 ${c.name}에서 직접 안내해 드립니다.`,children:n.jsx("ul",{className:"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 lg:gap-4",children:u.map(f=>{const d=f.icon,h="external"in f&&f.external;return n.jsx("li",{children:n.jsxs("a",{href:f.href,...h?{target:"_blank",rel:"noopener noreferrer"}:{},className:"panel flex h-full items-center justify-between gap-3 p-5 transition-opacity hover:opacity-80",children:[n.jsxs("span",{className:"flex min-w-0 items-center gap-3",children:[n.jsx(d,{className:"size-5 shrink-0 opacity-40"}),n.jsxs("span",{className:"min-w-0",children:[n.jsx("span",{className:"text-muted block text-[length:var(--fs-xs)] font-medium",children:f.label}),n.jsx("span",{className:"block break-all text-[length:var(--fs-sm)] font-semibold",children:f.value})]})]}),h&&n.jsx(Sn,{className:"size-4 shrink-0 opacity-40"})]})},f.key)})})})}function zy(){const i=le(),c=Q0(i);return c.length===0?null:n.jsx(vt,{id:"exhibition",tone:"alt",title:Ye(i,"exhibition","관람 안내"),lead:"방문 전 관람 조건을 확인해 주세요.",children:n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5",children:c.map(o=>n.jsx(kp,{label:o.label,value:o.value},o.label))})})}function Ey(){const[i,c]=q.useState([]);return q.useEffect(()=>c(w0()),[]),i}const wy=["봄","여름","가을","겨울"];function Ty(){const i=le(),c=i.local.festivals,o=Ey(),[u,f]=q.useState(null);if(c.length===0)return null;const d=wy.filter(y=>c.some(z=>z.season===y)),h=c.filter(y=>{var z;return!((z=y.season)!=null&&z.trim())}),p=[...o].reverse().find(y=>d.includes(y)),g="전체",x=u??p??null,v=x===g;return n.jsxs(vt,{id:"festival",title:"계절별 축제",lead:`${i.place.addressLocality??"이 지역"}의 축제와 행사를 계절로 묶었습니다.`,aside:x&&!v?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:["지금은 ",x," 축제입니다"]}):void 0,footnote:n.jsx(n.Fragment,{children:"한국관광공사 TourAPI 기준. 일정은 주최 측 사정으로 바뀔 수 있습니다."}),children:[d.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"계절",children:[...d,g].map(y=>{const z=x===y;return n.jsx("button",{type:"button",role:"tab","aria-selected":z,onClick:()=>f(y),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:z?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:y},y)})}),n.jsxs("div",{className:"space-y-8",children:[d.map(y=>{const z=c.filter(w=>w.season===y);return n.jsxs("div",{hidden:!v&&x!==null&&x!==y,children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:y}),n.jsx(Ih,{items:z,label:`${y} 축제`,remount:x})]},y)}),h.length>0&&n.jsxs("div",{children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:"계절 없이 열리는 행사"}),n.jsx(Ih,{items:h,label:"계절 없이 열리는 행사"})]})]})]})}function Ih({items:i,label:c,remount:o}){return n.jsx(gu,{label:c,children:i.map(u=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(u.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[u.imageUrl?n.jsx("img",{src:u.imageUrl,alt:`${u.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:u.name}),n.jsx("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:u.month})]}),n.jsxs("span",{className:"flex min-w-0 flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:u.name}),u.period&&n.jsx("span",{className:"text-[length:var(--fs-xs)] font-medium opacity-80",children:u.period}),u.location&&n.jsxs("span",{className:"text-muted flex items-start gap-1 text-[length:var(--fs-xs)]",children:[n.jsx(Ls,{className:"mt-0.5 size-3 shrink-0 opacity-60"}),n.jsx("span",{children:u.location})]}),u.description&&n.jsx("span",{className:"text-muted line-clamp-3 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed",children:u.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},u.name))},o??"all")}const _s=80,Ph=[{id:"all",label:"전체",test:()=>!0},{id:"w5",label:"걸어서 5분 이내",test:i=>i<=5*_s},{id:"w10",label:"걸어서 10분 이내",test:i=>i<=10*_s},{id:"far",label:"걸어서 10분 이상",test:i=>i>10*_s}];function Bo(i){if(Number.isFinite(i))return`도보 약 ${Math.max(1,Math.round(i/_s))}분`}function Cs(i){const c=/^([\d.]+)\s*(km|m)$/i.exec((i??"").trim());return c?Number(c[1])*(c[2].toLowerCase()==="km"?1e3:1):1/0}function Ay(){const i=le(),{local:c}=i,o=c.restaurants.length>0||c.attractions.length>0,[u,f]=q.useState("all");if(!o)return null;const d=[...c.restaurants,...c.attractions],h=v=>d.filter(y=>v.test(Cs(y.distanceText))).length,p=Ph.filter((v,y)=>y===0||h(v)>0&&h(v)<d.length),g=p.find(v=>v.id===u)??Ph[0],x=v=>g.test(Cs(v.distanceText));return n.jsxs(vt,{id:"guide",tone:"alt",title:"주변 안내",lead:`${i.place.addressLocality??"주변"} 지역의 맛집 · 명소 안내입니다.`,aside:c.syncedAt?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:[nu(c.syncedAt)," 갱신"]}):void 0,footnote:n.jsxs(n.Fragment,{children:["도보 시간은 숙소에서 잰 직선거리를 분속 ",_s,"m 로 환산한 값입니다. 실제로 걷는 길은 이보다 깁니다."]}),children:[p.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"거리",children:p.map(v=>{const y=v.id===u,z=h(v);return n.jsxs("button",{type:"button",role:"tab","aria-selected":y,onClick:()=>f(v.id),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:y?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[v.label," ",n.jsx("span",{className:"tabular-nums opacity-70",children:z})]},v.id)})}),n.jsxs("div",{className:"space-y-10",children:[c.restaurants.length>0&&n.jsx(ep,{title:"주변 맛집",icon:e0,places:c.restaurants,within:x}),c.attractions.length>0&&n.jsx(ep,{title:"주변 명소",icon:Ls,places:c.attractions,within:x})]})]})}function _y({icon:i,children:c}){return n.jsxs("h3",{className:"border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(i,{className:"size-4 opacity-50"}),n.jsx("span",{children:c})]})}function ep({title:i,icon:c,places:o,within:u}){const f=o.filter(u);return n.jsxs("div",{children:[n.jsx(_y,{icon:c,children:i}),n.jsx(gu,{label:i,children:f.map(d=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(d.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[d.imageUrl?n.jsx("img",{src:d.imageUrl,alt:`${d.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:d.name}),d.distanceText&&n.jsxs("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:[Bo(Cs(d.distanceText))??d.distanceText,n.jsx("span",{className:"ml-1 font-normal opacity-70",children:d.distanceText})]})]}),n.jsxs("span",{className:"flex flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:d.name}),d.description&&n.jsx("span",{className:"text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed",children:d.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},d.name))},f.length),n.jsx("ul",{hidden:!0,children:o.filter(d=>!u(d)).map(d=>n.jsxs("li",{children:[n.jsx("a",{href:Vo(d.searchQuery),rel:"nofollow",children:d.name}),d.distanceText&&n.jsxs("span",{children:[" ",d.distanceText,Bo(Cs(d.distanceText))&&` · ${Bo(Cs(d.distanceText))}`]}),d.description&&n.jsx("p",{children:d.description})]},d.name))}),f.length===0&&n.jsx("p",{className:"text-muted text-[length:var(--fs-sm)]",children:"이 거리 안에는 없습니다."})]})}function Cy(i){return i===0?"맑음":i>=1&&i<=3?"구름많음":i>=50&&i<=69?"비":i>=70&&i<=79?"눈":"흐림"}function My({initial:i,regionCode:c,latitude:o,longitude:u}){const[f,d]=q.useState(i);return q.useEffect(()=>{if(!c||o==null||u==null)return;const h=new AbortController;async function p(){var x;try{const v=new URLSearchParams({region_code:c,latitude:String(o),longitude:String(u)}),y=await fetch(`/v1/local/weather?${v}`,{signal:h.signal});if(!y.ok)return;const z=await y.json();if(!((x=z==null?void 0:z.result)!=null&&x.success)||!z.weather)return;d(w=>({temperature:Number(z.weather.temperature),condition:Cy(Number(z.weather.weather_code)),note:w==null?void 0:w.note,observedAt:z.weather.observed_at,stale:!!z.stale}))}catch{}}p();const g=window.setInterval(()=>void p(),600*1e3);return()=>{h.abort(),window.clearInterval(g)}},[o,u,c]),f}function ky(i){const c=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(i??"");if(!c)return;const[,,o,u,f,d]=c;return`${Number(o)}월 ${Number(u)}일 ${f}:${d} 관측`}function Oy(){var p,g,x,v;const i=le(),c=My({initial:i.local.weather,regionCode:i.place.regionCode,latitude:i.place.latitude,longitude:i.place.longitude});if(!c)return null;const o=O0(c.condition),u=D0(c.temperature),f=((g=(p=i.local.weather)==null?void 0:p.notes)==null?void 0:g[o])??c.note,d=(v=(x=i.local.weather)==null?void 0:x.tempNotes)==null?void 0:v[u],h=ky(c.observedAt);return n.jsx(vt,{id:"weather",tone:"alt",title:"오늘의 날씨",children:n.jsxs(bu,{className:"relative overflow-hidden",children:[n.jsx("div",{className:"w4-sky","data-mood":o,"aria-hidden":!0}),n.jsxs("div",{className:"relative flex flex-col gap-5 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-4 gap-y-1",children:[n.jsxs("p",{className:"flex items-baseline gap-2.5",children:[n.jsx("span",{className:"serif tabular-nums leading-none",style:{fontSize:"var(--fs-display)"},children:Math.round(c.temperature)}),n.jsx("span",{className:"text-[length:var(--fs-lead)] opacity-70",children:"°C"}),n.jsx("span",{className:"text-[length:var(--fs-lead)] font-semibold",children:c.condition})]}),h&&n.jsxs("span",{className:"text-muted text-[length:var(--fs-xs)] tabular-nums",children:[h,c.stale&&" · 최근 관측값"]})]}),f&&n.jsx("p",{className:"measure serif text-[length:var(--fs-lead)] leading-loose opacity-85",children:f}),d&&n.jsxs("p",{className:"measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:[n.jsx("span",{className:"border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold","aria-hidden":!0,children:u}),n.jsx("span",{children:d})]})]})]})})}function Dy(){const i=le(),c=lu(i),o=i.theme.sections.find(A=>A.id==="photos"),u=(o==null?void 0:o.variantId)??"photos.grid",[f,d]=q.useState(null),h=q.useRef(null),[p,g]=q.useState(0),[x,v]=q.useState({prev:!1,next:!0}),y=q.useCallback(()=>{const A=h.current;if(!A)return;const D=A.clientWidth,U=A.scrollWidth-D;g(D>0?Math.round(A.scrollLeft/D):0),v({prev:A.scrollLeft>8,next:A.scrollLeft<U-8})},[]);q.useEffect(()=>(y(),window.addEventListener("resize",y),()=>window.removeEventListener("resize",y)),[y]),xu({box:h,interval:pu,advance:()=>Cp(h.current,1)});const z=q.useCallback(A=>{const D=h.current;if(!D)return;const U=window.matchMedia("(prefers-reduced-motion: reduce)").matches;D.scrollBy({left:A*D.clientWidth,behavior:U?"auto":"smooth"})},[]),w=q.useCallback(()=>d(null),[]),N=q.useCallback(A=>d(D=>D===null?null:(D+A+c.length)%c.length),[c.length]);return q.useEffect(()=>{if(f===null)return;const A=U=>{U.key==="Escape"&&w(),U.key==="ArrowLeft"&&N(-1),U.key==="ArrowRight"&&N(1)},D=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",A),()=>{document.body.style.overflow=D,window.removeEventListener("keydown",A)}},[f,w,N]),c.length===0?null:n.jsxs(vt,{id:"gallery",title:(o==null?void 0:o.name)||"공간 갤러리",children:[u==="photos.carousel"?n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:h,onScroll:y,className:"flex snap-x snap-mandatory gap-3 overflow-x-auto [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden",children:c.map((A,D)=>n.jsx("li",{className:"w-full shrink-0 snap-center lg:w-auto",children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"aspect-4/3 rounded-lg"})},A.mediaId))}),c.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(tp,{dir:"prev",show:x.prev,onClick:()=>z(-1)}),n.jsx(tp,{dir:"next",show:x.next,onClick:()=>z(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden",children:[Math.min(p+1,c.length)," / ",c.length]})]})]}):u==="photos.masonry"?n.jsx("ul",{className:"columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3",children:c.map((A,D)=>n.jsx("li",{className:"break-inside-avoid",children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"rounded-lg",free:!0})},A.mediaId))}):n.jsx("ul",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4",children:c.map((A,D)=>n.jsx("li",{children:n.jsx(qo,{image:A,onOpen:()=>d(D),className:"aspect-square rounded-lg"})},A.mediaId))}),f!==null&&n.jsxs("div",{role:"dialog","aria-modal":"true","aria-label":"사진 크게 보기",className:"fixed inset-0 z-50 flex items-center justify-center bg-black/92 p-4 backdrop-blur-md",onClick:w,children:[n.jsx("button",{type:"button",onClick:w,"aria-label":"닫기",className:"tap absolute right-3 top-3 z-10 flex items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-5 sm:top-5",children:n.jsx(pp,{className:"size-6"})}),n.jsx(lp,{dir:"prev",onClick:()=>N(-1)}),n.jsx(lp,{dir:"next",onClick:()=>N(1)}),n.jsxs("figure",{className:"flex max-h-[85vh] w-full max-w-5xl flex-col items-center gap-3",onClick:A=>A.stopPropagation(),children:[n.jsx("img",{src:c[f].url,alt:c[f].alt,className:"max-h-[72vh] w-auto max-w-full rounded-lg object-contain"}),n.jsxs("figcaption",{className:"text-center text-[length:var(--fs-xs)] text-white/70",children:[c[f].caption??c[f].alt,n.jsxs("span",{className:"ml-2 tabular-nums",children:[f+1," / ",c.length]})]})]})]})]})}function tp({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-5"})})}function lp({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:u=>{u.stopPropagation(),c()},"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 ${i==="prev"?"left-2 sm:left-6":"right-2 sm:right-6"}`,children:n.jsx(o,{className:"size-6"})})}function qo({image:i,onOpen:c,className:o,free:u=!1}){return n.jsx("button",{type:"button",onClick:c,className:`border-line tpl-border group relative block w-full cursor-pointer overflow-hidden border ${o}`,"aria-label":`${i.alt} 크게 보기`,children:n.jsx("img",{src:i.url,alt:i.alt,loading:"lazy",decoding:"async",width:i.width,height:i.height,className:u?"h-auto w-full":"size-full object-cover transition-transform duration-500 group-hover:scale-105"})})}function ap(){const i=le(),{place:c,routes:o}=i,[u,f]=q.useState(!1),d=c.roadAddress??c.address;if(!d)return null;const{latitude:h,longitude:p}=c,g=h!=null&&p!=null,x=jv(c.name,h,p),v=async()=>{try{await navigator.clipboard.writeText(d),f(!0),setTimeout(()=>f(!1),2e3)}catch{}};return n.jsxs(vt,{id:"location",title:"오시는 길",lead:"주소와 주요 거점까지의 이동 시간을 안내합니다.",children:[n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-8",children:[g&&n.jsx("div",{className:"border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7",children:n.jsx("iframe",{title:`${c.name} 위치 지도`,src:yv(h,p),loading:"lazy",referrerPolicy:"no-referrer-when-downgrade",className:"block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"})}),n.jsx("div",{className:g?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:"panel p-5 sm:p-6",children:[n.jsx("p",{className:"label mb-2",children:"주소"}),n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[n.jsx("address",{className:"text-[length:var(--fs-lead)] font-bold not-italic",children:d}),n.jsxs("button",{type:"button",onClick:v,title:"주소 복사",className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70",children:[u?n.jsx(dp,{className:"size-3.5"}):n.jsx(kb,{className:"size-3.5"}),n.jsx("span",{children:u?"복사완료":"복사"})]})]}),c.phone&&n.jsxs("p",{className:"text-muted mt-2 text-[length:var(--fs-sm)]",children:["문의:"," ",n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2.5",children:[n.jsxs("a",{href:wp(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"네이버 길찾기"})]}),n.jsxs("a",{href:bv(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"카카오 길찾기"})]}),x&&n.jsxs("a",{href:x,className:"tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"티맵 길찾기"})]})]})]})})]}),o.length>0&&n.jsxs("div",{className:"mt-8",children:[n.jsxs("h3",{className:"mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(zb,{className:"size-4 opacity-50"}),n.jsx("span",{children:"주요 거점 소요시간"})]}),n.jsx("div",{className:"panel overflow-hidden",children:n.jsx("div",{className:"slider-viewport",children:n.jsxs("table",{className:"w-full min-w-[34rem] text-left text-[length:var(--fs-sm)]",children:[n.jsxs("caption",{className:"sr-only",children:[i.place.name,"에서 주요 거점까지의 이동 시간"]}),n.jsx("thead",{className:"panel-sunken border-line border-b font-semibold",children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",className:"p-4",children:"목적지"}),n.jsx("th",{scope:"col",className:"p-4",children:"차량"}),n.jsx("th",{scope:"col",className:"p-4",children:"도보 / 대중교통"}),n.jsx("th",{scope:"col",className:"p-4",children:"거리"}),n.jsx("th",{scope:"col",className:"hidden p-4 md:table-cell",children:"비고"})]})}),n.jsx("tbody",{className:"divide-line divide-y",children:o.map(y=>n.jsxs("tr",{children:[n.jsx("th",{scope:"row",className:"p-4 text-left font-bold",children:y.destination}),n.jsx("td",{className:"p-4 font-semibold",children:y.byCar??"—"}),n.jsx("td",{className:"p-4 opacity-70",children:y.byTransit??"—"}),n.jsx("td",{className:"text-muted p-4 tabular-nums",children:y.distanceText??"—"}),n.jsx("td",{className:"text-muted hidden p-4 md:table-cell",children:y.note??""})]},y.destination))})]})})})]})]})}function Ry(){const i=le(),c=R0(i);if(c.length===0)return null;const o=c.some(u=>u.sourceType===f0.TEMPLATE);return n.jsx(vt,{id:"faq",tone:"alt",title:"자주 묻는 질문",lead:o?void 0:"아래 답변은 모두 사업자가 확인한 내용입니다.",children:n.jsx("div",{className:c.length>4?"grid gap-3 lg:grid-cols-2 lg:gap-4":"space-y-3",children:c.map((u,f)=>n.jsxs("details",{open:f===0,className:"panel group h-fit overflow-hidden",children:[n.jsxs("summary",{className:"tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsx("span",{className:"serif text-muted shrink-0",children:"Q."}),n.jsx("span",{className:"flex-1",children:u.question}),n.jsx("span",{className:"text-muted shrink-0 transition-transform group-open:rotate-45",children:"+"})]}),n.jsx("div",{className:"border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:u.answer})]},u.faqId))})})}function Op(){var d,h,p,g;const i=le(),{place:c,site:o}=i,u=A0(i),f=c.roadAddress??c.address;return n.jsx("footer",{className:"w-full pb-28 pt-14 md:pb-14",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid grid-cols-1 gap-8 border-b border-current/15 pb-10 md:grid-cols-12",children:[n.jsxs("div",{className:"space-y-4 md:col-span-7",children:[n.jsx("p",{className:"serif text-[length:var(--fs-lead)] font-bold",children:c.name}),n.jsxs("div",{className:"space-y-2 text-[length:var(--fs-sm)] opacity-75",children:[f&&n.jsxs("p",{className:"flex items-start gap-2",children:[n.jsx(Ls,{className:"mt-1 size-4 shrink-0 opacity-60"}),n.jsx("span",{children:f})]}),c.phone&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(_t,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),c.email&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(mp,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-2 hover:underline",children:c.email})]})]})]}),u.length>0&&n.jsxs("nav",{"aria-label":"공식 채널",className:"md:col-span-5",children:[n.jsx("h2",{className:"label mb-3 !text-current opacity-60",children:"공식 채널"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:u.map(x=>n.jsx("li",{children:n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10",children:[n.jsx("span",{children:ml(x)}),n.jsx(Db,{className:"size-3.5"})]})},x.url))})]})]}),n.jsxs("div",{className:"flex flex-col gap-3 pt-6 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",c.name]}),((d=c.legal)==null?void 0:d.representative)&&n.jsxs("span",{children:["대표: ",c.legal.representative]}),((h=c.legal)==null?void 0:h.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",c.legal.businessRegistrationNumber]}),((p=c.legal)==null?void 0:p.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",c.legal.mailOrderNumber]}),((g=c.legal)==null?void 0:g.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호",": ",c.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}function Tn(){const i=le(),c=i.place.phone,u=Ze(i)[0],f=i.links.find(d=>d.confirmed&&/kakao/i.test(d.url));return!c&&!u&&!f?null:n.jsxs("nav",{"aria-label":"연락 · 예약",className:"border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 94%, transparent)"},children:[n.jsxs("a",{href:"#location",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":"오시는 길",children:[n.jsx(Ls,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"위치"})]}),f&&n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":ml(f),children:[n.jsx(Io,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"문의"})]}),c&&n.jsxs("a",{href:`tel:${c}`,className:"tap border-line flex flex-1 items-center justify-center gap-1.5 rounded-lg border text-[length:var(--fs-sm)] font-bold",children:[n.jsx(_t,{className:"size-4"}),n.jsx("span",{children:"전화"})]}),u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)})]})}const tl="var(--tpl-card, #fafafa)",Ce="var(--tpl-border, #d6d3d1)",We="color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)",Ms="var(--tpl-inverse, #1c1917)",ra="var(--tpl-bg, #ffffff)",ul="var(--tpl-text, #09090b)";function Uy({n:i}){return n.jsx("span",{className:"mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums",style:{backgroundColor:We,color:ra},children:i})}function Rl({id:i,name:c,subtitle:o,count:u,link:f,children:d,dark:h}){const p=hc(),g=p==="reservation"?iu:p==="oasi"?cu:p==="studio"?ru:p==="pastel"?ou:p==="editorial"?au:null,x=f?n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-75 transition-opacity hover:opacity-100 hover:underline",children:[f.label," →"]}):void 0;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{backgroundColor:h?Ms:"var(--tpl-surface, #fafafa)",color:h?ra:ul,paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[g?n.jsx(g,{id:i,title:c,lead:o,aside:x}):n.jsxs("header",{className:"mb-8 sm:mb-10",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2",children:[n.jsx("h2",{id:`${i}-heading`,className:"h2",children:c}),x]}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:o})]}),d,u&&n.jsx("p",{className:"mt-6 text-[length:var(--fs-xs)] opacity-60",children:u})]})})}function Gs({children:i,label:c,arrows:o="header",onSelect:u,onReady:f}){return n.jsx(gu,{label:c,gap:1,arrows:o,onSelect:u,onReady:f,children:i})}function fa({source:i,verified:c,imageCredit:o}){return!(i!=null&&i.name)&&!o?null:n.jsxs("p",{className:"flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70",children:[(i==null?void 0:i.name)&&n.jsxs("span",{children:["출처 ·"," ",i.url?n.jsx("a",{href:i.url,target:"_blank",rel:"noopener noreferrer nofollow",className:"underline underline-offset-2",children:i.name}):i.name]}),o&&n.jsxs("span",{children:["사진 · ",o]})]})}function Hs(i,c,o){return`총 ${i}${o}`}const np=We;function Dp(){const i=le(),c=dl(i,"songs"),[o,u]=q.useState(0);if(c.items.length===0)return null;const f=c.items[o]??c.items[0],d=h=>[h.artist,h.year?String(h.year):void 0,h.lyricist||h.composer?`작사 ${h.lyricist??"미상"} / 작곡 ${h.composer??"미상"}`:void 0,h.label].filter(Boolean).join(" · ");return n.jsxs(Rl,{id:"songs",name:c.title||Ye(i,"songs","가요 다방"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"곡"),children:[n.jsxs("div",{className:"tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]",style:{backgroundColor:Ms,color:ra,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsxs("div",{className:"relative mx-auto size-[220px]",children:[n.jsx("div",{className:"absolute inset-0 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 16%, transparent)"}}),n.jsx("div",{className:"w4-disc w4-spin absolute inset-2 rounded-full",style:{"--lbl":f.labelColor||np,"--w4-vinyl":Ms},children:n.jsx("span",{className:"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 30%, transparent)"}})}),n.jsxs("div",{"aria-hidden":!0,className:"absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6",children:[n.jsx("span",{className:"absolute inset-y-[3px] left-0 right-4 rounded-sm",style:{backgroundColor:"color-mix(in oklab, currentColor 65%, transparent)"}}),n.jsx("span",{className:"absolute -top-2 right-0 size-6 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 55%, transparent)"}})]})]}),n.jsx("div",{className:"min-w-0",children:c.items.map((h,p)=>n.jsxs("div",{hidden:p!==o,className:"space-y-3",children:[n.jsxs("p",{className:"text-[10px] tracking-[0.24em]",style:{color:We},children:["A면 · ",p+1," / ",c.items.length]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h2)] leading-tight",children:h.title}),n.jsx("p",{className:"text-[length:var(--fs-xs)] opacity-60",children:d(h)}),h.story&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:h.story}),h.connection&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed",style:{color:We},children:h.connection}),n.jsxs("a",{href:gv(`${h.title} ${h.artist??""}`.trim()),target:"_blank",rel:"noopener noreferrer nofollow",className:"inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",style:{color:We},children:[n.jsx(a0,{className:"size-4"}),n.jsx("span",{children:"유튜브에서 듣기"})]}),n.jsx("span",{className:"block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60",style:{borderColor:"color-mix(in oklab, currentColor 35%, transparent)"},children:"◎ 가사 대신 이야기 — 원문은 싣지 않습니다"}),n.jsx(fa,{source:h.source,verified:h.verified})]},`disc-${h.title}-${p}`))})]}),c.items.length>1&&n.jsx("div",{className:"mt-4 flex flex-wrap justify-center gap-4",children:c.items.map((h,p)=>n.jsxs("button",{type:"button",onClick:()=>u(p),"aria-pressed":p===o,className:"w-24 text-center",children:[n.jsx("span",{className:"w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105",style:{"--lbl":h.labelColor||np,"--w4-vinyl":Ms,boxShadow:p===o?`0 0 0 2px ${We}`:void 0}}),n.jsx("span",{className:"mt-2 block truncate text-[10px] leading-tight opacity-70",children:h.title})]},`${h.title}-${p}`))})]})}function Rp(){const i=le(),c=dl(i,"daily"),[o,u]=q.useState();if(q.useEffect(()=>{const g=new Date;u(`${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`)},[]),c.items.length===0)return null;const f=[...c.items].sort((g,x)=>g.monthDay.localeCompare(x.monthDay)),d=o===void 0?1:Math.max(0,(()=>{const g=f.findIndex(v=>v.monthDay===o);if(g>=0)return g;const x=f.findIndex(v=>v.monthDay>o);return x>=0?x:0})()),h=g=>f[(d+g+f.length)%f.length],p=f.length===1?[h(0)]:f.length===2?[h(0),h(1)]:[h(-1),h(0),h(1)];return n.jsx(Rl,{id:"daily",name:c.title||Ye(i,"daily","오늘의 한 장"),subtitle:c.subtitle,count:Hs(f.length,c.unverified,"장"),children:n.jsx("div",{className:"flex items-start justify-center gap-4 sm:gap-6",children:f.map((g,x)=>{const v=p.indexOf(g);return n.jsx(Hy,{page:g,hidden:v<0,isToday:p.length===1||v===1,muted:p.length>1&&v!==1},`${g.monthDay}-${x}`)})})})}function Hy({page:i,isToday:c,muted:o,hidden:u}){const[f,d]=i.monthDay.split("-");return n.jsxs("article",{hidden:u,className:`w4-paper shrink-0 border transition-opacity ${o?"hidden w-[168px] opacity-45 sm:block":"w-[254px] max-w-full"}`,style:{backgroundColor:tl,borderColor:c?We:Ce,boxShadow:c?"5px 6px 0 color-mix(in oklab, currentColor 14%, transparent)":void 0},"aria-current":c?"date":void 0,children:[n.jsx("div",{className:"w4-perf h-3.5 border-b border-dashed",style:{"--tear":tl,borderColor:Ce}}),n.jsxs("div",{className:"border-b px-5 pb-3 pt-5 text-center",style:{borderColor:Ce},children:[n.jsxs("p",{className:"text-[11px] tracking-[0.2em] opacity-60",children:[f,"月"]}),n.jsx("p",{className:`serif mt-1 font-bold leading-none ${o?"text-4xl":"text-6xl"}`,style:{color:We},children:String(Number(d)||d)}),c&&n.jsx("p",{className:"mt-1.5 text-[10px] tracking-[0.3em]",style:{color:We},children:"오늘"})]}),n.jsxs("div",{className:"space-y-2 px-5 pb-5 pt-4",children:[i.category&&n.jsx("p",{className:"text-[10px] tracking-[0.16em] opacity-60",children:i.category}),n.jsx("h3",{className:"serif text-base font-bold leading-snug",children:i.title}),!o&&i.body&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.body}),!o&&n.jsx("div",{className:"border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})]})}function Up(){const i=le(),c=dl(i,"people");return c.items.length===0?null:n.jsx(Rl,{id:"people",name:c.title||Ye(i,"people","인물 열전"),subtitle:c.subtitle,count:c.items.some(o=>!o.imageUrl)?`${Hs(c.items.length,c.unverified,"명")} · 사진이 없는 인물은 이름 활자로 대신합니다`:Hs(c.items.length,c.unverified,"명"),dark:!0,children:n.jsxs("div",{className:"tpl-border border",style:{backgroundColor:Ms,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"}),n.jsx("div",{className:"px-3 py-4",children:n.jsx(Gs,{label:"인물 목록",arrows:"overlay",children:c.items.map((o,u)=>n.jsxs("article",{className:"w-[210px] shrink-0 snap-center",children:[o.imageUrl?n.jsx("img",{src:o.imageUrl,alt:`${o.name} 사진`,loading:"lazy",decoding:"async",className:"aspect-3/4 w-full border object-cover",style:{borderColor:"color-mix(in oklab, currentColor 28%, transparent)"}}):n.jsx("span",{className:"serif grid aspect-3/4 w-full place-items-center border text-5xl",style:{backgroundColor:"color-mix(in oklab, currentColor 14%, transparent)",borderColor:"color-mix(in oklab, currentColor 28%, transparent)"},"aria-hidden":!0,children:o.name.trim().charAt(0)}),n.jsxs("h3",{className:"serif mt-3 text-base font-bold",children:[o.name,o.aka&&n.jsxs("span",{className:"ml-1.5 text-xs opacity-60",children:["호 ",o.aka]})]}),n.jsx("p",{className:"mt-0.5 text-[11px] opacity-60",children:[o.role,o.years].filter(Boolean).join(" · ")}),o.oneLine&&n.jsx("p",{className:"mt-2 text-[13px] leading-relaxed opacity-85",children:o.oneLine}),n.jsx("div",{className:"mt-2",children:n.jsx(fa,{source:o.source,verified:o.verified,imageCredit:o.imageCredit})})]},`${o.name}-${u}`))})}),n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"})]})})}function Hp(){const i=le(),c=dl(i,"chronicle");if(c.items.length===0)return null;const o=[...c.items].sort((f,d)=>f.year==null?d.year==null?0:1:d.year==null?-1:f.year-d.year),u=o.filter(f=>f.turning===!0).length;return n.jsx(Rl,{id:"chronicle",name:c.title||Ye(i,"chronicle","시간의 골목"),subtitle:c.subtitle,count:`붉은 점은 도시의 성격이 바뀐 해입니다 · ${u}개 / 전체 ${o.length}개`,children:n.jsx(Gs,{label:"연표",children:o.map((f,d)=>{const h=f.turning===!0;return n.jsxs("article",{className:"w-[228px] shrink-0 snap-center",children:[n.jsx("p",{className:"serif text-3xl font-bold leading-none",style:{color:h?We:void 0},children:f.year??"연도 미상"}),n.jsxs("div",{className:"relative my-3 h-3",children:[n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 border-t",style:{borderColor:Ce,right:d===o.length-1?"calc(100% - 12px)":0}}),n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2",style:{backgroundColor:h?We:tl,borderColor:h?We:Ce}})]}),n.jsxs("div",{className:"flex gap-2.5 pr-5",children:[f.imageUrl&&n.jsx("img",{src:f.imageUrl,alt:`${f.title} 사진`,loading:"lazy",decoding:"async",className:"size-14 shrink-0 border object-cover",style:{borderColor:Ce}}),n.jsxs("div",{className:"min-w-0 flex-1 space-y-1.5",children:[n.jsx("h3",{className:"serif text-base font-bold",children:f.title}),f.summary&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:f.summary}),f.place&&n.jsxs("p",{className:"text-[11px] opacity-60",children:["지금 이 자리 · ",f.place]}),n.jsx(fa,{source:f.source,verified:f.verified,imageCredit:f.imageCredit})]})]})]},`${f.year??"x"}-${f.title}-${d}`)})})})}function Lp(){const i=le(),c=dl(i,"postcard");return c.items.length===0?null:n.jsx(Rl,{id:"postcard",name:c.title||Ye(i,"postcard","오늘의 엽서"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"장"),children:n.jsx(Gs,{label:"엽서 목록",children:c.items.map((o,u)=>n.jsxs("article",{className:"w4-paper w-[304px] shrink-0 snap-center border p-4",style:{backgroundColor:tl,borderColor:ul},children:[o.imageUrl&&n.jsx("img",{src:o.imageUrl,alt:`${o.place||o.postmark||"엽서"} 사진`,loading:"lazy",decoding:"async",className:"block aspect-3/2 w-full border object-cover",style:{borderColor:Ce}}),o.imageUrl&&n.jsxs("p",{className:"mt-1 mb-3.5 text-[10px] leading-tight opacity-45",children:["사진 · ",o.imageCredit||"출처 표기 없음"]}),n.jsxs("div",{className:"grid grid-cols-[minmax(0,1fr)_auto] gap-3",children:[n.jsxs("div",{className:"min-w-0 border-r pr-3",style:{borderColor:Ce},children:[n.jsxs("p",{className:"serif text-[17px] leading-snug",children:["“",o.line,"”"]}),o.hashtags&&o.hashtags.length>0&&n.jsx("p",{className:"mt-2 text-[11px] opacity-60",children:o.hashtags.join(" ")})]}),n.jsxs("div",{className:"flex w-[62px] flex-col items-center gap-3",children:[n.jsxs("span",{className:"grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60",style:{borderColor:Ce},children:["郵票",n.jsx("br",{}),"10원"]}),n.jsx("span",{className:"serif grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight",style:{borderColor:We,color:We},children:o.postmark||o.place||"소인"})]})]}),n.jsx("div",{className:"mt-3 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:o.source,verified:o.verified})})]},`${o.line}-${u}`))})})}function Ly(){const i=le(),c=dl(i,"quiz"),[o,u]=q.useState(new Set);if(c.items.length===0)return null;const f=d=>u(h=>{const p=new Set(h);return p.has(d)?p.delete(d):p.add(d),p});return n.jsx(Rl,{id:"quiz",name:c.title||Ye(i,"quiz","뒤집어 보는 질문"),subtitle:c.subtitle,count:`정답은 두지 않습니다 — 힌트와 출처까지만 · 총 ${c.items.length}문항`,children:n.jsx(Gs,{label:"질문 목록",children:c.items.map((d,h)=>{const p=o.has(h);return n.jsx("button",{type:"button",onClick:()=>f(h),"aria-pressed":p,"aria-label":`${h+1}번 문제 ${p?"앞면 보기":"힌트 보기"}`,className:`w4-flip ${p?"w4-flip-on":""} h-[260px] w-[262px] shrink-0 text-left`,children:n.jsxs("span",{className:"w4-flip-inner block size-full",children:[n.jsxs("span",{className:"w4-flip-face w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:ul},children:[n.jsxs("span",{className:"text-[10px] tracking-[0.16em] opacity-60",children:["문제 ",h+1,d.level?` · ${d.level}`:""]}),n.jsx("span",{className:"serif text-[17px] font-bold leading-snug",children:d.question}),n.jsx("span",{className:"mt-auto text-[10px] tracking-[0.16em]",style:{color:We},children:"눌러서 힌트 보기 →"})]}),n.jsxs("span",{className:"w4-flip-face w4-flip-back w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:We},children:[n.jsx("span",{className:"text-[10px] tracking-[0.16em]",style:{color:We},children:"힌트"}),n.jsx("span",{className:"text-[13px] leading-relaxed opacity-85",children:d.hint??"힌트가 아직 없습니다."}),n.jsxs("span",{className:"mt-auto space-y-1 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:[d.topic&&n.jsx("span",{className:"block text-[10px] opacity-55",children:d.topic}),n.jsx(fa,{source:d.source,verified:d.verified})]})]})]})},`${d.question}-${h}`)})})})}const Dl=256,Ts=318,Nn=168,sp=34,By=16,ip=4;function Bp(i,c,o){const u=Dl*2**o,f=i*Math.PI/180;return{x:(c+180)/360*u,y:(1-Math.log(Math.tan(f)+1/Math.cos(f))/Math.PI)/2*u}}function qy(i){if(i.length<2)return 15;for(let c=By;c>ip;c-=1){const o=i.map(d=>Bp(d.lat,d.lng,c)),u=Math.max(...o.map(d=>d.x))-Math.min(...o.map(d=>d.x)),f=Math.max(...o.map(d=>d.y))-Math.min(...o.map(d=>d.y));if(u<=Ts-sp*2&&f<=Nn-sp*2)return c}return ip}function Yy(i,c){const o=d=>d.latitude!=null&&d.longitude!=null?{name:d.searchQuery??d.name,lat:d.latitude,lng:d.longitude}:void 0,u=[];let f=c;return i.forEach((d,h)=>{const p=o(d);p&&(u.push({label:String(h+1),...p,stop:d,from:f&&!Gy(f,p)?f:void 0}),f=p)}),u}function Gy(i,c){return Math.abs(i.lat-c.lat)<1e-5&&Math.abs(i.lng-c.lng)<1e-5}function $y(i){const o=i.map(u=>({...u}));for(let u=1;u<o.length;u+=1)for(let f=0;f<6;f+=1){const d=o.slice(0,u).find(v=>Math.hypot(o[u].x-v.x,o[u].y-v.y)<27);if(!d)break;const h=o[u].x-d.x,p=o[u].y-d.y,g=Math.hypot(h,p)||1,x=(27-g)/g;o[u].x+=(h||1)*x,o[u].y+=p*x}return o}function Qy(i){return i.from?vv(i.from,{name:i.stop.name,lat:i.lat,lng:i.lng}):wp(i.stop.searchQuery??i.stop.name,i.lat,i.lng)}function Xy({stops:i}){const{place:c}=le(),o=c.latitude!=null&&c.longitude!=null?{name:c.name,lat:c.latitude,lng:c.longitude}:void 0,u=i.filter(N=>N.latitude!=null&&N.longitude!=null);if(u.length===0||u.length===1&&i.length>1)return null;const f=Yy(i,o),d=qy(f),h=$y(f.map(N=>Bp(N.lat,N.lng,d))),p=(Math.min(...h.map(N=>N.x))+Math.max(...h.map(N=>N.x)))/2,g=(Math.min(...h.map(N=>N.y))+Math.max(...h.map(N=>N.y)))/2,x=p-Ts/2,v=g-Nn/2,y=2**d,z=[];for(let N=Math.floor(x/Dl);N<=Math.floor((x+Ts)/Dl);N+=1)for(let A=Math.floor(v/Dl);A<=Math.floor((v+Nn)/Dl);A+=1){if(A<0||A>=y)continue;const D=(N%y+y)%y;z.push({key:`${N}-${A}`,url:`https://tile.openstreetmap.org/${d}/${D}/${A}.png`,left:N*Dl-x,top:A*Dl-v})}const w=h.map(N=>`${(N.x-x).toFixed(1)},${(N.y-v).toFixed(1)}`).join(" ");return n.jsxs("div",{className:"relative mt-3.5 overflow-hidden border-y",style:{height:Nn,borderColor:Ce,backgroundColor:"#e8e5df"},children:[z.map(N=>n.jsx("img",{src:N.url,alt:"",width:Dl,height:Dl,loading:"lazy",decoding:"async",className:"pointer-events-none absolute max-w-none",style:{left:N.left,top:N.top,filter:"grayscale(1) contrast(0.95) opacity(0.55)"}},N.key)),n.jsx("svg",{className:"pointer-events-none absolute inset-0",width:Ts,height:Nn,viewBox:`0 0 ${Ts} ${Nn}`,children:n.jsx("polyline",{points:w,fill:"none",stroke:We,strokeWidth:"2",strokeDasharray:"5 4",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.85"})}),f.map((N,A)=>{const D=h[A];return n.jsx("a",{href:Qy(N),target:"_blank",rel:"noopener noreferrer",title:N.from?`${N.from.name} → ${N.stop.name} 길찾기`:`${N.stop.name} — 네이버 지도`,className:"absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full px-1.5 text-[11px] font-bold whitespace-nowrap shadow-sm transition-transform hover:scale-110",style:{left:D.x-x,top:D.y-v,backgroundColor:We,color:ra,border:`1.5px solid ${ra}`},children:N.label},N.label)}),n.jsx("span",{className:"absolute right-1 bottom-0.5 bg-white/70 px-1 text-[9px] leading-tight text-stone-600",children:"© OpenStreetMap"})]})}function Vy(i){const c=Math.floor(i/60),o=i%60;return[c>0?`${c}시간`:"",o>0?`${o}분`:""].filter(Boolean).join(" ")||"0분"}function Jo(i){var c;return(c=i.days)!=null&&c.length?i.days.map(o=>({label:o.label,startTime:o.startTime,stops:o.stops??[]})):[{startTime:i.startTime,stops:i.stops??[]}]}function Zy({item:i,badge:c,startTime:o,stops:u,first:f,placeName:d}){const h=E0({name:i.name,startTime:o,stops:u});return n.jsxs("article",{className:"w4-paper w-[320px] shrink-0 snap-center border",style:{backgroundColor:tl,borderColor:Ce},children:[n.jsxs("div",{className:"flex items-center justify-between gap-2 border-b px-4 py-2.5",style:{borderColor:Ce},children:[n.jsx("span",{className:"border px-2 py-0.5 text-[11px] font-bold",style:f?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:c}),n.jsxs("span",{className:"text-[11px] opacity-60",children:[h.from,"–",h.to," · ",Vy(h.totalMinutes)]})]}),n.jsxs("div",{className:"space-y-1.5 px-4 pt-3.5",children:[n.jsx("h3",{className:"serif text-lg font-bold",children:i.name}),f&&i.audience&&n.jsx("p",{className:"text-[12px] opacity-60",children:i.audience}),f&&i.why&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.why}),f&&n.jsxs("p",{className:"text-[11px] opacity-50",children:[d,"에서 출발 · ",h.from]})]}),n.jsx(Xy,{stops:h.stops.map(p=>p.stop)}),n.jsx("ol",{className:"mt-3 px-4 pb-3",children:h.stops.map((p,g)=>n.jsxs("li",{className:"grid grid-cols-[46px_minmax(0,1fr)] gap-2.5",children:[n.jsx("span",{className:"serif pt-2 text-[12px] tabular-nums opacity-75",children:p.time}),n.jsxs("div",{className:"border-l pb-5 pl-3",style:{borderColor:Ce},children:[p.move>0&&n.jsxs("p",{className:"pt-1 text-[10px] opacity-50",children:["↓ ",p.move,"분 이동"]}),n.jsxs("div",{className:"flex items-start gap-2.5 pt-1",children:[p.stop.imageUrl&&n.jsx("img",{src:p.stop.imageUrl,alt:`${p.stop.name} 사진`,loading:"lazy",decoding:"async",className:"size-12 shrink-0 object-cover",style:{border:`1px solid ${Ce}`}}),n.jsxs("span",{className:"min-w-0",children:[n.jsxs("span",{className:"block text-sm font-bold",children:[n.jsx(Uy,{n:g+1}),p.stop.name]}),p.stop.note&&n.jsx("span",{className:"mt-0.5 block text-[12px] leading-relaxed opacity-75",children:p.stop.note})]})]}),n.jsxs("p",{className:"mt-0.5 text-[10px] opacity-50",children:[p.time,"–",p.until,p.stop.searchQuery&&` · 지도 검색 ${p.stop.searchQuery}`]})]})]},`${p.stop.name}-${g}`))}),n.jsx("div",{className:"border-t border-dashed px-4 py-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})}const Yo="그 밖의 일정";function Ky(){var g;const i=le(),c=dl(i,"itinerary"),u=(c.items.length>0?c.items:((g=i.local)==null?void 0:g.itineraries)??[]).filter(x=>Jo(x).some(v=>v.stops.length>0)),f=[...new Set(u.map(x=>{var v;return((v=x.duration)==null?void 0:v.trim())||Yo}))],[d,h]=q.useState(null),p=d??f[0];return u.length===0?null:n.jsxs(Rl,{id:"itinerary",name:c.title||Ye(i,"itinerary","추천 일정"),subtitle:c.subtitle,count:"시각은 출발 시각과 머무는 시간으로 계산한 것입니다",children:[f.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"묵는 기간",children:f.map(x=>{const v=p===x;return n.jsxs("button",{type:"button",role:"tab","aria-selected":v,onClick:()=>h(x),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:v?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[x,n.jsx("span",{className:"ml-1.5 font-normal opacity-60",children:u.filter(y=>{var z;return(((z=y.duration)==null?void 0:z.trim())||Yo)===x}).length})]},x)})}),f.map(x=>n.jsx("div",{hidden:x!==p,children:n.jsx(Jy,{tab:x,items:u.filter(v=>{var y;return(((y=v.duration)==null?void 0:y.trim())||Yo)===x}),placeName:i.place.name},p)},x))]})}function Jy({tab:i,items:c,placeName:o}){const u=q.useMemo(()=>{let x=0;return c.map(v=>{const y=x;return x+=Jo(v).length,{item:v,start:y}})},[c]),[f,d]=q.useState(0),h=q.useRef(null),p=q.useCallback(x=>{h.current=x},[]),g=u.reduce((x,v)=>v.start<=f?v.start:x,0);return n.jsxs(n.Fragment,{children:[u.length>1&&n.jsx("ul",{className:"border-line mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3",children:u.map(({item:x,start:v})=>{const y=v===g;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>{var z;return(z=h.current)==null?void 0:z.scrollTo(v)},"aria-current":y,className:`text-left text-[length:var(--fs-sm)] underline-offset-4 transition-opacity hover:opacity-100 ${y?"font-bold underline":"opacity-55 hover:underline"}`,children:x.name})},`${x.name}-${v}`)})}),n.jsx(Gs,{label:`${i} 일정`,onSelect:d,onReady:p,children:u.flatMap(({item:x,start:v},y)=>Jo(x).map((z,w)=>n.jsx(Zy,{item:x,badge:z.label??x.duration??`${w+1}일차`,startTime:z.startTime,stops:z.stops,first:w===0,placeName:o},`${x.name}-${v}-${w}`)))})]})}function Wy(){const i=le(),c=dl(i,"video"),[o,u]=q.useState(),f=q.useRef(null),[d,h]=q.useState({prev:!1,next:!0}),p=q.useCallback(()=>{const v=f.current;if(!v)return;const y=v.scrollWidth-v.clientWidth;h({prev:v.scrollLeft>8,next:v.scrollLeft<y-8})},[]);q.useEffect(()=>(p(),window.addEventListener("resize",p),()=>window.removeEventListener("resize",p)),[p]);const g=q.useCallback(v=>{const y=f.current;if(!y)return;const z=window.matchMedia("(prefers-reduced-motion: reduce)").matches;y.scrollBy({left:v*y.clientWidth*.85,behavior:z?"auto":"smooth"})},[]);if(xu({box:f,interval:pu,advance:()=>Cp(f.current)}),c.items.length===0)return null;const x=c.items.length>1;return n.jsx(Rl,{id:"video",name:c.title||Ye(i,"video","영상으로 보기"),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,children:n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:f,onScroll:p,className:x?"flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:gap-4 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden":"grid grid-cols-1 gap-4",children:c.items.map((v,y)=>{const z=x0(v.url),w=g0(v.url),N=o===y;return n.jsx("li",{className:x?"shrink-0 basis-[86%] snap-start sm:basis-[56%] lg:basis-auto":"",children:n.jsxs("figure",{className:"space-y-2",children:[n.jsx("div",{className:"tpl-border relative mx-auto overflow-hidden border",style:{borderColor:Ce,backgroundColor:tl,aspectRatio:w?"9 / 16":"16 / 9",width:w?"min(100%, 22rem)":"100%"},children:N&&z?n.jsx("iframe",{src:v0(z),title:v.caption||"영상",allow:"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",allowFullScreen:!0,className:"size-full border-0"}):n.jsxs("a",{href:v.url,target:"_blank",rel:"noopener noreferrer",onClick:A=>{z&&(A.preventDefault(),u(y))},"aria-label":`${v.caption||"영상"} 재생`,className:"group block size-full",children:[z?n.jsx("img",{src:b0(z),alt:"",loading:"lazy",decoding:"async",className:"size-full object-cover"}):n.jsx("span",{className:"text-muted grid size-full place-items-center px-4 text-center text-[length:var(--fs-xs)]",children:"유튜브 영상이 아닙니다 — 눌러서 원본으로 갑니다"}),n.jsx("span",{className:"absolute inset-0 grid place-items-center",children:n.jsx("span",{className:"grid size-14 place-items-center rounded-full backdrop-blur transition-transform group-hover:scale-110",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 70%, transparent)",color:"var(--tpl-bg, #fff)"},children:n.jsx(Vb,{className:"size-6"})})})]})}),v.caption&&n.jsx("figcaption",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:v.caption}),n.jsx(fa,{source:v.source,verified:v.verified})]})},`${v.url}-${y}`)})}),x&&n.jsxs(n.Fragment,{children:[n.jsx(cp,{dir:"prev",onClick:()=>g(-1),disabled:!d.prev}),n.jsx(cp,{dir:"next",onClick:()=>g(1),disabled:!d.next})]})]})})}function cp({dir:i,onClick:c,disabled:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":i==="prev"?"이전 영상":"다음 영상",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 lg:hidden ${i==="prev"?"left-1":"right-1"}`,children:n.jsx(u,{className:"size-5"})})}const Fy={1:"한",2:"두",3:"세",4:"네",5:"다섯"},Iy=[{id:"songs",label:"가요 다방",Component:Dp},{id:"daily",label:"오늘의 한 장",Component:Rp},{id:"people",label:"인물 열전",Component:Up},{id:"chronicle",label:"시간의 골목",Component:Hp},{id:"postcard",label:"오늘의 엽서",Component:Lp}];function Py(){const i=le(),c=Iy.filter(f=>dl(i,f.id).items.length>0),[o,u]=q.useState(0);return c.length===0?null:n.jsxs(n.Fragment,{children:[n.jsx("section",{id:"story","aria-labelledby":"story-heading",className:"paper w-full",style:{backgroundColor:"var(--tpl-surface, #fafafa)",color:ul,paddingTop:"var(--section-space)",paddingBottom:"calc(var(--section-space) * 0.55)"},children:n.jsxs("div",{className:"shell",children:[n.jsx("h2",{id:"story-heading",className:"h2",children:Ye(i,"story","군산 이야기")}),n.jsxs("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:["이 도시를 ",Fy[c.length]??`${c.length}`," 갈래로 봅니다. 하나씩 골라 보세요."]}),n.jsx("div",{className:"mt-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"군산 이야기",children:c.map((f,d)=>{const h=d===o;return n.jsx("button",{type:"button",role:"tab","aria-selected":h,"aria-controls":f.id,onClick:()=>u(d),className:"rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:h?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:f.label},f.id)})})]})}),c.map((f,d)=>n.jsx("div",{hidden:d!==o,children:n.jsx(f.Component,{})},f.id))]})}const e1={open:"진행 중",soon:"곧 시작",closed:"종료"};function rp(i,c){return i.endDate&&i.endDate<c?"closed":i.startDate&&i.startDate>c?"soon":"open"}function Go(i,c){return i.kind==="공지"&&!i.startDate&&!i.endDate?"공지":c&&e1[c]}function $o(i){const c=f=>{const d=/^(\d{4})-(\d{2})-(\d{2})$/.exec(f??"");return d?`${Number(d[2])}월 ${Number(d[3])}일`:void 0},o=c(i.startDate),u=c(i.endDate);if(o&&u)return o===u?o:`${o} – ${u}`;if(o)return`${o}부터`;if(u)return`${u}까지`}function t1(){const i=le(),c=dl(i,"event"),[o,u]=q.useState();q.useEffect(()=>u(new Date().toLocaleDateString("sv-SE")),[]);const[f,d]=q.useState(null),h=q.useCallback(()=>d(null),[]);if(q.useEffect(()=>{if(f===null)return;const x=y=>{y.key==="Escape"&&h()},v=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",x),()=>{document.body.style.overflow=v,window.removeEventListener("keydown",x)}},[f,h]),c.items.length===0)return null;const p=f===null?void 0:c.items[f],g=p&&o?rp(p,o):void 0;return n.jsxs(Rl,{id:"event",name:c.title||Ye(i,"event",`${i.place.name} 소식`),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,count:"자세한 내용과 최신 소식은 원문에서 확인해 주세요.",children:[n.jsx("ul",{className:"grid gap-5 sm:grid-cols-2 lg:grid-cols-3",children:c.items.map((x,v)=>{const y=o?rp(x,o):void 0,z=Go(x,y),w=$o(x),N=y==="closed";return n.jsxs("li",{className:"w4-paper relative flex flex-col border-2",style:{backgroundColor:tl,borderColor:Ce,opacity:N?.6:1,boxShadow:N?"none":"3px 3px 0 color-mix(in oklab, currentcolor 12%, transparent)"},children:[n.jsx("button",{type:"button",onClick:()=>d(v),"aria-label":`${x.title} 자세히 보기`,className:"absolute inset-0 z-20 cursor-pointer"}),n.jsx("div",{className:x.imageUrl?"relative overflow-hidden border-b-2":"contents",style:x.imageUrl?{borderColor:Ce}:void 0,children:x.imageUrl?n.jsx("img",{src:x.imageUrl,alt:`${x.title} 안내 이미지`,loading:"lazy",decoding:"async",className:"aspect-4/3 w-full object-cover",style:N?{filter:"grayscale(1)"}:void 0}):null}),n.jsxs("div",{className:"flex flex-1 flex-col gap-2.5 p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[z&&n.jsx("span",{className:"border-2 px-2 py-0.5 text-[11px] font-bold tracking-[0.08em]",style:{transform:"rotate(-3deg)",backgroundColor:y==="open"&&z!=="공지"?We:tl,color:y==="open"&&z!=="공지"?ra:ul,borderColor:y==="open"&&z!=="공지"?We:Ce},children:z}),w&&n.jsx("span",{className:"text-[11px] font-semibold tabular-nums opacity-55",children:w})]}),n.jsx("h3",{className:"serif text-lg font-bold leading-snug",children:x.title}),x.summary&&n.jsx("p",{className:"line-clamp-2 text-[13px] leading-relaxed opacity-80",children:x.summary}),x.howTo&&n.jsx("p",{className:"border-2 border-dashed px-3 py-2 text-[12px] leading-relaxed",style:{borderColor:Ce},children:x.howTo}),x.body&&n.jsx("p",{className:`whitespace-pre-line text-[12px] leading-relaxed opacity-70 ${x.imageUrl?"line-clamp-3":"line-clamp-[9]"}`,children:x.body}),n.jsx("p",{className:"mt-auto pt-2 text-[12px] font-bold opacity-70",children:"자세히 보기 →"})]})]},`${x.title}-${v}`)})}),p&&n.jsx("div",{role:"dialog","aria-modal":"true","aria-label":p.title,className:"fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6",onClick:h,children:n.jsxs("article",{onClick:x=>x.stopPropagation(),className:"w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2",style:{backgroundColor:tl,borderColor:Ce,color:ul},children:[p.imageUrl&&n.jsx("img",{src:p.imageUrl,alt:`${p.title} 안내 이미지`,className:"max-h-[46vh] w-full object-contain",style:{backgroundColor:"color-mix(in oklab, currentcolor 6%, transparent)"}}),n.jsxs("div",{className:"space-y-4 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[Go(p,g)&&n.jsx("span",{className:"border-2 px-2.5 py-1 text-[11px] font-bold tracking-[0.08em]",style:{borderColor:Ce},children:Go(p,g)}),$o(p)&&n.jsx("span",{className:"text-[12px] font-semibold tabular-nums opacity-60",children:$o(p)})]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h3)] font-bold leading-snug",children:p.title}),p.summary&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.summary}),p.howTo&&n.jsx("p",{className:"border-2 border-dashed px-4 py-3 text-[length:var(--fs-sm)] leading-relaxed",style:{borderColor:Ce},children:p.howTo}),p.body&&n.jsx("p",{className:"whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.body}),n.jsxs("div",{className:"flex items-center justify-between gap-4 pt-2",children:[p.postUrl?n.jsx("a",{href:p.postUrl,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",children:"원문 보기 →"}):n.jsx("span",{}),n.jsxs("button",{type:"button",onClick:h,className:"tap inline-flex items-center gap-1.5 border-2 px-4 text-[length:var(--fs-sm)] font-bold",style:{borderColor:Ce},children:[n.jsx(pp,{className:"size-4"}),n.jsx("span",{children:"닫기"})]})]})]})]})})]})}const l1={songs:Dp,daily:Rp,people:Up,chronicle:Hp,postcard:Lp,quiz:Ly,itinerary:Ky,video:Wy,event:t1,story:Py};function a1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(i1,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(c1,{}),n.jsx(Tn,{})]})}function qp(){const i=le(),c=ft(i);return[{id:"intro",href:"#about",fallback:"소개"},{id:c.path,href:"#units",fallback:c.label},{id:"info",href:"#info",fallback:"이용 정보"},{id:"booking",href:"#booking",fallback:"예약 안내"},{id:"photos",href:"#gallery",fallback:"사진"},{id:"map",href:"#location",fallback:"오시는 길"},{id:"local",href:"#guide",fallback:"주변 정보"},{id:"faq",href:"#faq",fallback:"자주 묻는 질문"}].filter(o=>Kt(i,o.id)).map(o=>({label:Ye(i,o.id,o.fallback),href:o.href}))}const n1=24,s1="0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)";function i1(){const i=le(),c=qp(),[o,u]=q.useState(!1);q.useEffect(()=>{const d=()=>u(window.scrollY>n1);return d(),window.addEventListener("scroll",d,{passive:!0}),()=>window.removeEventListener("scroll",d)},[]);const f=o?"opacity-70 transition-opacity hover:opacity-100":"underline-offset-4 hover:underline";return n.jsx("header",{className:`fixed inset-x-0 top-0 z-40 w-full transition-colors duration-300 ${o?"border-line border-b backdrop-blur-md":""}`,style:o?{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"}:{color:"var(--tpl-bg, #ffffff)",textShadow:s1},children:n.jsxs("div",{className:"shell grid h-20 grid-cols-[1fr_auto] items-center gap-4 lg:grid-cols-[1fr_auto_1fr]",children:[n.jsx("a",{href:"#top",className:"min-w-0",children:n.jsx("span",{className:"serif block truncate",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.12em"},children:i.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center justify-center gap-10 lg:flex",children:c.map(d=>n.jsx("a",{href:d.href,className:`py-1 text-[length:var(--fs-sm)] font-light ${f}`,children:d.label},d.href))}),n.jsxs("div",{className:"flex items-center justify-end gap-2",children:[i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:`tap hidden items-center text-[length:var(--fs-sm)] font-light tabular-nums sm:inline-flex ${f}`,children:i.place.phone}),c.length>0&&n.jsxs("details",{className:"lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center justify-center px-2 text-[length:var(--fs-sm)] font-light marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:"메뉴"}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line absolute inset-x-0 top-full border-b border-t",style:{backgroundColor:"var(--tpl-surface, #fff)",color:"var(--tpl-text)",textShadow:"none"},children:n.jsx("ul",{className:"divide-line shell divide-y",children:c.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center text-[length:var(--fs-sm)] font-light",children:d.label})},d.href))})})]})]})]})})}function c1(){var x,v,y,z;const i=le(),{place:c,site:o,narrative:u}=i,f=qp(),d=c.roadAddress??c.address,h=Ze(i)[0],p=wn(i),g=u.about[0]??u.summary;return n.jsx("footer",{className:"border-line paper w-full border-t pb-28 md:pb-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)",paddingTop:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid gap-10 md:grid-cols-3 md:gap-12",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"serif",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.1em"},children:c.name}),g&&n.jsx("p",{className:"text-muted measure mt-4 text-[length:var(--fs-sm)] leading-loose",children:g})]}),n.jsxs("dl",{className:"min-w-0 space-y-5",children:[d&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"주소"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:d})]}),c.phone&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"전화"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light tabular-nums",children:n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-4 hover:underline",children:c.phone})})]}),c.email&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"이메일"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-4 hover:underline",children:c.email})})]}),h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"예약"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"underline-offset-4 hover:underline",children:ks(h)})})]})]}),f.length>0&&n.jsx("nav",{"aria-label":"사이트 메뉴",className:"min-w-0",children:n.jsx("ul",{className:"space-y-3",children:f.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.href,className:"text-muted text-[length:var(--fs-sm)] font-light underline-offset-4 hover:underline",children:w.label})},w.href))})})]}),n.jsx("hr",{className:"border-line mt-12 border-t"}),p.length>0&&n.jsx("ul",{className:"mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted text-[length:var(--fs-xs)] font-light underline-offset-4 hover:underline",children:ks(w)})},w.url))}),n.jsxs("div",{className:"text-muted mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-center text-[length:var(--fs-xs)] font-light",children:[n.jsxs("span",{children:["상호 ",c.name]}),((x=c.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표 ",c.legal.representative]}),((v=c.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호 ",c.legal.businessRegistrationNumber]}),((y=c.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고 ",c.legal.mailOrderNumber]}),((z=c.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호"," ",c.legal.licenseNumber]}),n.jsxs("span",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}const r1=` +.oasi-page main > section:not([data-oasi]) { + background-color: transparent !important; + background-image: none !important; + border-color: transparent !important; + color: var(--tpl-text) !important; + padding-block: var(--oasi-gap) 0 !important; +} +.oasi-page main > section:not([data-oasi]) .shell { + max-width: none; + padding-inline: 0; +} +.oasi-page main > section#about > .shell > .grid { + display: block; +} +.oasi-page main > section#about > .shell > .grid > * + * { + margin-top: 2.5rem; +} +.oasi-page main > section#summary dl { + grid-template-columns: minmax(0, 1fr); +} + +/* + * 붙여넣기 아이템 카드 — 소식 · 영상 · 추천 일정 (2026-09-04, 사장님: "1,2,3안 다 맞추세요") + * + * ★ 이 카드들은 .panel 이 아니라 제 인라인 style 로 면을 그린다(items/common.tsx 의 + * ITEM_CARD·ITEM_BORDER). 그래서 위 .panel 규칙이 안 닿았고, 섹션 띠를 다 지운 3안에서 + * **소식 카드만 갱지 결에 2px 테두리를 두른 상자로 혼자 서 있었다.** + * ★ w4-paper 의 결은 1안(레트로 갱지)의 것이다. 여기서는 결을 지우고 테두리를 실선 한 겹으로 + * 낮춘다. 상자를 아예 없애지는 않는다 — 전단 여섯 장이 경계 없이 붙으면 어디까지가 한 장인지 + * 안 보인다. 면은 빼고 테두리만 남기는 게 이 안의 타협점이다. + * ★ 배경은 transparent 로 두지 않는다. 사진 없는 공지 카드가 종이에 바로 얹히면 + * 본문과 구분이 사라진다 — 아주 옅은 틴트 한 겹만 남긴다. + * ★ 영상은 뺐다 (2026-09-04, 사장님: "영상 border 안 예쁘다") + * 영상 카드는 상자가 아니라 **표지 사진 한 장**이고, 쇼츠라 9:16 세로다. li 에 틴트를 깔면 + * 영상보다 넓은 회색 판이 뒤에 깔려 액자처럼 보인다. 사진은 제 테두리가 곧 경계다. + */ +.oasi-page section#event li, +.oasi-page section#itinerary article { + background-image: none !important; + background-color: color-mix(in oklab, currentcolor 3%, transparent) !important; + border-width: 1px !important; + box-shadow: none !important; +} + +.oasi-page .panel, +.oasi-page .panel-sunken { + background-color: transparent; + border-width: 0; + border-radius: 0; + box-shadow: none; +} + +.oasi-page main > section#faq > .shell > div:last-of-type { + row-gap: 0; + column-gap: 2.5rem; +} +.oasi-page main > section#faq details { + margin-block: 0; + border-top: 1px solid var(--site-line); +} +.oasi-page main > section#faq summary { + padding-inline: 0; +} +.oasi-page main > section#faq summary > span:first-child { + width: 1.125rem; +} +.oasi-page main > section#faq summary + div { + border-top-width: 0; + padding-inline: 1.875rem 0; + padding-top: 0.5rem; +} + +.oasi-page main > section#info dl.panel > div { + padding-inline: 0; +} +.oasi-page main > section#info dl > div.panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} + +.oasi-page main > section#location .panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):first-child { + padding-left: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):last-child { + padding-right: 0; +} + +.oasi-page main > section#weather .panel { + padding-inline: 0; +} + +.oasi-page main > section#guide .panel > span:last-child { + padding-inline: 0; +} +@media (min-width: 40rem) { + .oasi-page main > section#festival .panel > span:last-child { + padding-inline: 0; + } +} +`,o1={"--oasi-gap":"clamp(3.5rem, 7vw, var(--tpl-section-space, 4rem))",backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"};function u1({children:i}){var v,y,z,w;const c=le(),{place:o}=c,u=ft(c),f=Ze(c)[0],d=wn(c),h=[{label:"처음",href:"#top",show:!0},{label:"소개",href:"#about",show:Kt(c,"intro")},{label:u.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(N=>N.show),p=[...Oa(c).map(N=>`${N.label} ${N.value}`),...c.theme.sections.filter(N=>{var A;return N.enabled&&((A=N.name)==null?void 0:A.trim())}).map(N=>N.name.trim())].slice(0,3),g=c.narrative.tagline??c.narrative.heroSubline,x=o.roadAddress??o.address;return n.jsxs("div",{className:"oasi-page flex min-h-screen w-full flex-col",style:o1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:r1}}),n.jsxs("div",{className:"mx-auto flex w-full max-w-[72rem] flex-col gap-10 px-5 pb-16 pt-10 lg:flex-row lg:gap-14 lg:px-10 lg:pb-24 lg:pt-16",children:[n.jsxs("header",{className:"lg:sticky lg:top-12 lg:h-fit lg:w-[18.75rem] lg:shrink-0",children:[n.jsxs("a",{href:"#top",className:"block",children:[n.jsx("span",{className:"serif block leading-none",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.14em"},children:o.name}),o.englishName&&n.jsx("span",{className:"text-muted mt-3 block text-[length:var(--fs-xs)] tracking-[0.24em]",children:o.englishName})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-12 lg:mt-24",children:n.jsx("ul",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 lg:block",children:h.map((N,A)=>n.jsx("li",{children:n.jsx("a",{href:N.href,"aria-current":A===0?"true":void 0,className:`tap inline-flex items-center text-[length:var(--fs-sm)] transition-opacity hover:opacity-100 ${A===0?"":"text-muted"}`,children:N.label})},N.label))})}),f&&n.jsx("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(f)})]}),n.jsxs("div",{className:"flex min-w-0 flex-1 flex-col",children:[(p.length>0||g)&&n.jsxs("div",{className:"border-line flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6",children:[p.length>0&&n.jsx("ul",{className:"text-muted flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)]",children:p.map((N,A)=>n.jsxs("li",{className:"flex items-center gap-4",children:[A>0&&n.jsx("span",{"aria-hidden":!0,className:"opacity-40",children:"|"}),n.jsx("span",{children:N})]},N))}),g&&n.jsx("p",{className:"text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right",children:g})]}),i]})]}),n.jsx("footer",{className:"border-line mt-auto w-full border-t",children:n.jsxs("div",{className:"mx-auto grid w-full max-w-[72rem] grid-cols-1 gap-8 px-5 pb-28 pt-10 text-[length:var(--fs-xs)] leading-[1.9] sm:grid-cols-3 lg:px-10 lg:pb-14",children:[n.jsxs("p",{children:["[",o.name,"]"]}),d.length>0?n.jsx("ul",{className:"space-y-1",children:d.map(N=>n.jsx("li",{children:n.jsx("a",{href:N.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-[5px]",children:ks(N)})},N.url))}):n.jsx("span",{}),n.jsxs("div",{className:"text-muted space-y-1 sm:text-right",children:[x&&n.jsx("p",{children:x}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-[5px]",children:o.phone})}),o.email&&n.jsx("p",{children:o.email}),((v=o.legal)==null?void 0:v.representative)&&n.jsxs("p",{children:["대표 ",o.legal.representative]}),((y=o.legal)==null?void 0:y.businessRegistrationNumber)&&n.jsxs("p",{children:["사업자등록번호 ",o.legal.businessRegistrationNumber]}),((z=o.legal)==null?void 0:z.mailOrderNumber)&&n.jsxs("p",{children:["통신판매업신고 ",o.legal.mailOrderNumber]}),((w=o.legal)==null?void 0:w.licenseNumber)&&n.jsxs("p",{children:[o.legal.licenseLabel??"인허가번호"," ",o.legal.licenseNumber]}),n.jsxs("p",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:c.site.updatedAt,children:qs(c.site.updatedAt)})]})]})]})}),n.jsx(Tn,{})]})}function f1({children:i}){var z,w,N,A;const c=le(),{place:o,narrative:u,site:f}=c,d=ft(c),h=En(c),p=Oa(c),g=Ze(c)[0],x=c.links.filter(D=>D.confirmed),v=o.roadAddress??o.address,y=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:d.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변 정보",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(D=>D.show);return n.jsxs("div",{className:"flex min-h-screen w-full flex-col lg:flex-row",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("aside",{className:"w-full shrink-0 px-6 pb-8 pt-8 lg:sticky lg:top-0 lg:h-screen lg:w-[260px] lg:overflow-y-auto lg:px-7 lg:py-10",style:{backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"flex h-full min-w-0 flex-col",children:[n.jsx("a",{href:"#top",className:"block min-w-0",children:n.jsx("h1",{className:"serif min-w-0 break-keep font-black",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.12em",lineHeight:1.25},children:o.name})}),(u.tagline??u.heroSubline)&&n.jsx("p",{className:"text-muted mt-3 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:u.tagline??u.heroSubline}),(h||p.length>0)&&n.jsxs("dl",{className:"mt-7 space-y-2.5",children:[h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:h})]}),p.map(D=>n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:D.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:D.value})]},D.label))]}),g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-5 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg)"},children:ht(g)}),o.phone&&n.jsxs("a",{href:`tel:${o.phone}`,className:"tap mt-2 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--tpl-surface-alt)",color:"var(--tpl-text)"},children:["전화 ",o.phone]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-10",children:n.jsx("ul",{className:"space-y-0.5",children:y.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.href,className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-sm)] transition-colors hover:text-current",children:D.label})},D.label))})}),x.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"mt-12",children:n.jsx("ul",{className:"space-y-0.5",children:x.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-xs)] underline-offset-4 transition-colors hover:text-current hover:underline",children:ml(D)})},D.url))})}),n.jsxs("div",{className:"text-muted mt-14 space-y-1 pb-2 text-[length:var(--fs-xs)] lg:mt-auto lg:pt-14",children:[v&&n.jsx("p",{className:"break-keep",children:v}),o.phone&&n.jsx("p",{children:o.phone}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline-offset-2 hover:underline",children:o.email})}),n.jsxs("div",{className:"flex flex-wrap gap-x-2 gap-y-0.5 pt-3",children:[n.jsxs("span",{children:["상호: ",o.name]}),((z=o.legal)==null?void 0:z.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((w=o.legal)==null?void 0:w.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((N=o.legal)==null?void 0:N.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((A=o.legal)==null?void 0:A.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline pt-1",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:f.updatedAt,children:qs(f.updatedAt)})]})]})]})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col pb-24 lg:pb-0",children:i}),n.jsx(Tn,{})]})}const d1=` +.pastel-page > main > section:not([data-pastel]) { + border-color: transparent; + background-image: none; + color: var(--pastel-ink) !important; +} +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+1) { background-color: var(--pastel-1) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+2) { background-color: var(--pastel-soft) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+3) { background-color: var(--pastel-2) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n) { background-color: var(--pastel-3) !important; } +`,m1={"--pastel-1":"var(--tpl-card)","--pastel-2":"var(--tpl-accent)","--pastel-3":"var(--tpl-secondary)","--pastel-soft":"var(--tpl-bg)","--pastel-ink":"var(--tpl-text)","--section-space":"clamp(2.75rem, 8vw, calc(var(--tpl-section-space, 4rem) * 1.4))",backgroundColor:"var(--pastel-soft)",color:"var(--pastel-ink)"};function h1({children:i}){const c=le(),o=ft(c),u=wn(c),f=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:o.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:c.faqs.length>0}].filter(d=>d.show);return n.jsxs("div",{className:"pastel-page flex min-h-screen w-full flex-col",style:m1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:d1}}),n.jsx("header",{className:"sticky top-0 z-40 w-full",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsx("a",{href:"#top",className:"flex min-w-0 items-center gap-2",children:n.jsx("span",{className:"serif truncate italic",style:{fontSize:"var(--fs-lead)",fontWeight:800,display:"inline-block",transform:"rotate(-1.5deg)"},children:c.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-8 lg:flex",children:f.map(d=>n.jsx("a",{href:d.href,className:"py-1 text-[length:var(--fs-xs)] font-bold tracking-[0.2em] opacity-70 transition-opacity hover:opacity-100",children:d.label},d.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1",children:[c.place.phone&&n.jsx("a",{href:`tel:${c.place.phone}`,"aria-label":"전화",className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-1)"},children:n.jsx(_t,{className:"size-4"})}),u.map(d=>n.jsx("a",{href:d.url,target:"_blank",rel:"noopener noreferrer","aria-label":ml(d),className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-2)"},children:/instagram/i.test(d.url)?n.jsx(Ub,{className:"size-4"}):n.jsx(Io,{className:"size-4"})},d.url)),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-full marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"absolute right-0 top-[calc(100%+0.5rem)] z-50 w-48 overflow-hidden rounded-2xl",style:{backgroundColor:"var(--tpl-surface, #fff)",boxShadow:"0 10px 30px rgb(0 0 0 / 0.12)"},children:n.jsx("ul",{children:f.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-bold tracking-[0.08em]",children:d.label})},d.label))})})]})]})]})}),i,n.jsx("div",{style:{"--tpl-inverse":"var(--pastel-3)","--tpl-bg":"var(--pastel-ink)"},children:n.jsx(Op,{})}),n.jsx(Tn,{})]})}function p1({children:i}){var x,v,y,z;const c=le(),{place:o,site:u}=c,f=Ep(),d=x1(f),h=Ze(c)[0],p=c.links.filter(w=>w.confirmed),g=o.roadAddress??o.address;return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("header",{className:"sticky top-0 z-40 w-full border-b-2 backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-bg) 90%, transparent)",borderColor:"var(--tpl-text)"},children:n.jsxs("div",{className:"flex h-14 items-center justify-between gap-4 px-4 sm:px-6 xl:px-8",children:[n.jsx("a",{href:"#top",className:"serif min-w-0 truncate text-[length:var(--fs-lead)] font-extrabold",children:o.name}),n.jsxs("div",{className:"flex shrink-0 items-center gap-4 text-[length:var(--fs-sm)]",children:[o.phone&&n.jsx("a",{href:`tel:${o.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:o.phone}),h&&n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center font-bold underline underline-offset-4",children:ht(h)})]})]})}),n.jsxs("div",{className:"flex w-full flex-1",children:[f.length>0&&n.jsx("aside",{className:"border-line sticky top-14 hidden h-[calc(100vh-3.5rem)] w-44 shrink-0 self-start border-r xl:block",style:{backgroundImage:Os},children:n.jsx("nav",{"aria-label":"차례",className:"flex h-full flex-col justify-center gap-0.5 py-8 pl-6 pr-3",children:f.map(w=>{const N=w.anchor===d;return n.jsxs("a",{href:`#${w.anchor}`,"aria-current":N?"true":void 0,className:"flex items-baseline gap-2 py-2 text-[length:var(--fs-sm)] leading-tight motion-safe:transition-opacity",style:{opacity:N?1:.35},children:[n.jsx("span",{className:"shrink-0 text-[length:var(--fs-xs)] font-bold tabular-nums",children:w.no}),n.jsx("span",{className:"truncate",style:{fontWeight:N?700:400},children:w.label})]},w.anchor)})})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col",children:i})]}),n.jsx("footer",{className:"w-full pb-28 pt-12 md:pb-16",style:{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os},children:n.jsxs("div",{className:"shell",children:[n.jsx("div",{"aria-hidden":!0,className:"h-0.5 w-full bg-current opacity-70"}),n.jsxs("div",{className:"grid grid-cols-1 gap-8 pt-8 md:grid-cols-12",children:[n.jsxs("div",{className:"md:col-span-5",children:[n.jsx("p",{className:"serif",style:{fontSize:"clamp(1.5rem, 4vw, 2.5rem)",fontWeight:800,lineHeight:1.05},children:o.name}),o.englishName&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-xs)] uppercase tracking-[0.18em] opacity-55",children:o.englishName})]}),n.jsxs("address",{className:"space-y-2 font-serif text-[length:var(--fs-sm)] not-italic opacity-80 md:col-span-4",children:[g&&n.jsx("p",{children:g}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-4",children:o.phone})}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline underline-offset-4",children:o.email})})]}),p.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"md:col-span-3",children:n.jsx("ul",{className:"space-y-1.5 text-[length:var(--fs-sm)]",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:ml(w)})},w.url))})})]}),n.jsxs("div",{className:"mt-10 flex flex-col gap-2 border-t border-current/20 pt-5 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",o.name]}),((x=o.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((v=o.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((y=o.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((z=o.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:u.updatedAt,children:qs(u.updatedAt)})]})]})]})}),n.jsx(Tn,{})]})}function x1(i){const c=i.map(f=>f.anchor).join(","),[o,u]=q.useState("");return q.useEffect(()=>{if(typeof IntersectionObserver>"u")return;const f=c.split(",").map(h=>document.getElementById(h)).filter(h=>h!==null);if(f.length===0)return;const d=new IntersectionObserver(h=>{const p=h.find(g=>g.isIntersecting);p&&u(p.target.id)},{rootMargin:"-45% 0px -50% 0px"});return f.forEach(h=>d.observe(h)),()=>d.disconnect()},[c]),o}function g1(){const i=le(),c=i.place.category===Ma.LODGING,o={intro:ny,info:sy,rooms:Lo,menu:Lo,programs:Lo,booking:c?Fh:xy,space:Ny,inquiry:Sy,exhibition:zy,photos:Dy,festival:Ty,local:Ay,weather:Oy,map:ap,faq:Ry,...l1},u=new Set;return n.jsxs("main",{className:"w-full flex-1",children:[n.jsx(xv,{}),!Kt(i,"intro")&&n.jsx(Hh,{}),i.theme.sections.filter(f=>f.enabled&&f.id!=="hero").map(f=>{const d=o[f.id];if(!d)return null;const h=d.name;return u.has(h)?null:(u.add(h),n.jsxs("div",{"data-editor-id":f.id,style:{display:"contents"},children:[n.jsx(d,{}),f.id==="intro"&&n.jsx(Hh,{})]},f.id))}),!Kt(i,"map")&&n.jsx(ap,{}),c&&!F0(i,"booking")&&n.jsx(Fh,{})]})}function Wo({payload:i}){const c=zp(i.theme.templateId),o=c==="reservation"?a1:c==="oasi"?u1:c==="studio"?f1:c==="pastel"?h1:c==="editorial"?p1:b1;return n.jsx(n0,{payload:i,children:n.jsx(o,{children:n.jsx(g1,{})})})}function b1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(I0,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(Op,{}),n.jsx(Tn,{})]})}function v1(i){const{colors:c,look:o}=i.theme,u=T0(c),f={"--tpl-primary":c.primary,"--tpl-secondary":c.secondary,"--tpl-bg":c.bg,"--tpl-card":c.card,"--tpl-text":c.text,"--tpl-accent":c.accent,"--tpl-surface":u.surface,"--tpl-surface-alt":u.surfaceAlt,"--tpl-inverse":u.inverse,"--tpl-border":u.border};if(o){const d=p=>p&&!/[<>{};]/.test(p)?p:void 0,h=[["--tpl-font-heading",d(o.fontHeading)],["--tpl-font-body",d(o.fontBody)],["--tpl-heading-tracking",d(o.headingTracking)],["--tpl-heading-weight",d(o.headingWeight)],["--tpl-section-space",d(o.sectionSpace)],["--tpl-border-width",d(o.borderWidth)],["--tpl-radius",d(o.radius)],["--tpl-shadow",d(o.shadow)],["--tpl-texture",d(o.texture)]];for(const[p,g]of h)g&&(f[p]=g)}return f}const y1=[[/Noto Sans KR/i,"family=Noto+Sans+KR:wght@300..900"],[/Noto Serif KR/i,"family=Noto+Serif+KR:wght@300..700"],[/Gugi/i,"family=Gugi"],[/Gowun Batang/i,"family=Gowun+Batang:wght@400;700"],[/Nanum Pen Script/i,"family=Nanum+Pen+Script"]];function j1(i){var u,f;const c=["Noto Sans KR","Noto Serif KR",((u=i.theme.look)==null?void 0:u.fontHeading)??"",((f=i.theme.look)==null?void 0:f.fontBody)??""].join(" ");return`https://fonts.googleapis.com/css2?${y1.filter(([d])=>d.test(c)).map(([,d])=>d).join("&")}&display=swap`}const oc=document.getElementById("root"),op=window.__SITE_PAYLOAD__;async function N1(i){const c=(()=>{try{return localStorage.getItem("o2o-web4ai.accessToken")}catch{return null}})(),o=await fetch(`/v1/place/${i}/site/preview`,{headers:c?{Authorization:`Bearer ${c}`}:{}});if(!o.ok)throw new Error(`HTTP ${o.status}`);const u=await o.json();for(const[d,h]of Object.entries(v1(u)))document.documentElement.style.setProperty(d,h);const f=document.createElement("link");f.rel="stylesheet",f.href=j1(u),document.head.appendChild(f),await new Promise(d=>{f.addEventListener("load",()=>d(),{once:!0}),f.addEventListener("error",()=>d(),{once:!0}),setTimeout(d,2500)});try{await document.fonts.ready}catch{}Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:u})}))}const up=new URLSearchParams(window.location.search).get("placeId");op?Qo.hydrateRoot(oc,n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:op})})):up?N1(up).catch(i=>{oc.textContent=`미리보기를 불러오지 못했습니다 — ${i instanceof Error?i.message:String(i)}`}):tb(async()=>{const{MOONLIGHT_STAY_PAYLOAD:i}=await import("./moonlight-stay-B0tfisrz.js");return{MOONLIGHT_STAY_PAYLOAD:i}},[]).then(({MOONLIGHT_STAY_PAYLOAD:i})=>{Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:i})}))});export{_h as F,bt as L,Ma as P,f0 as S,S1 as a}; diff --git a/solution/site/scripts/mockup/vendor/retired/index-DDxwteyn.js b/solution/site/scripts/mockup/vendor/retired/index-DDxwteyn.js new file mode 100644 index 0000000..37be998 --- /dev/null +++ b/solution/site/scripts/mockup/vendor/retired/index-DDxwteyn.js @@ -0,0 +1,340 @@ +(function(){const c=document.createElement("link").relList;if(c&&c.supports&&c.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))u(f);new MutationObserver(f=>{for(const d of f)if(d.type==="childList")for(const h of d.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&u(h)}).observe(document,{childList:!0,subtree:!0});function o(f){const d={};return f.integrity&&(d.integrity=f.integrity),f.referrerPolicy&&(d.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?d.credentials="include":f.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(f){if(f.ep)return;f.ep=!0;const d=o(f);fetch(f.href,d)}})();const Pg="modulepreload",eb=function(i){return"/"+i},xh={},tb=function(c,o,u){let f=Promise.resolve();if(o&&o.length>0){let h=function(x){return Promise.all(x.map(v=>Promise.resolve(v).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const p=document.querySelector("meta[property=csp-nonce]"),g=(p==null?void 0:p.nonce)||(p==null?void 0:p.getAttribute("nonce"));f=h(o.map(x=>{if(x=eb(x),x in xh)return;xh[x]=!0;const v=x.endsWith(".css"),y=v?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${x}"]${y}`))return;const z=document.createElement("link");if(z.rel=v?"stylesheet":Pg,v||(z.as="script"),z.crossOrigin="",z.href=x,g&&z.setAttribute("nonce",g),document.head.appendChild(z),v)return new Promise((w,N)=>{z.addEventListener("load",w),z.addEventListener("error",()=>N(new Error(`Unable to preload CSS for ${x}`)))})}))}function d(h){const p=new Event("vite:preloadError",{cancelable:!0});if(p.payload=h,window.dispatchEvent(p),!p.defaultPrevented)throw h}return f.then(h=>{for(const p of h||[])p.status==="rejected"&&d(p.reason);return c().catch(d)})};var Mo={exports:{}},zs={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gh;function lb(){if(gh)return zs;gh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.fragment");function o(u,f,d){var h=null;if(d!==void 0&&(h=""+d),f.key!==void 0&&(h=""+f.key),"key"in f){d={};for(var p in f)p!=="key"&&(d[p]=f[p])}else d=f;return f=d.ref,{$$typeof:i,type:u,key:h,ref:f!==void 0?f:null,props:d}}return zs.Fragment=c,zs.jsx=o,zs.jsxs=o,zs}var bh;function ab(){return bh||(bh=1,Mo.exports=lb()),Mo.exports}var n=ab(),ko={exports:{}},ue={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var vh;function nb(){if(vh)return ue;vh=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),h=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),v=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),z=Symbol.iterator;function w(S){return S===null||typeof S!="object"?null:(S=z&&S[z]||S["@@iterator"],typeof S=="function"?S:null)}var N={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,D={};function U(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}U.prototype.isReactComponent={},U.prototype.setState=function(S,L){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,L,"setState")},U.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function X(){}X.prototype=U.prototype;function Z(S,L,V){this.props=S,this.context=L,this.refs=D,this.updater=V||N}var P=Z.prototype=new X;P.constructor=Z,T(P,U.prototype),P.isPureReactComponent=!0;var $=Array.isArray;function K(){}var G={H:null,A:null,T:null,S:null},ee=Object.prototype.hasOwnProperty;function se(S,L,V){var J=V.ref;return{$$typeof:i,type:S,key:L,ref:J!==void 0?J:null,props:V}}function Q(S,L){return se(S.type,L,S.props)}function ae(S){return typeof S=="object"&&S!==null&&S.$$typeof===i}function ye(S){var L={"=":"=0",":":"=2"};return"$"+S.replace(/[=:]/g,function(V){return L[V]})}var Ge=/\/+/g;function Qe(S,L){return typeof S=="object"&&S!==null&&S.key!=null?ye(""+S.key):L.toString(36)}function ce(S){switch(S.status){case"fulfilled":return S.value;case"rejected":throw S.reason;default:switch(typeof S.status=="string"?S.then(K,K):(S.status="pending",S.then(function(L){S.status==="pending"&&(S.status="fulfilled",S.value=L)},function(L){S.status==="pending"&&(S.status="rejected",S.reason=L)})),S.status){case"fulfilled":return S.value;case"rejected":throw S.reason}}throw S}function k(S,L,V,J,re){var fe=typeof S;(fe==="undefined"||fe==="boolean")&&(S=null);var oe=!1;if(S===null)oe=!0;else switch(fe){case"bigint":case"string":case"number":oe=!0;break;case"object":switch(S.$$typeof){case i:case c:oe=!0;break;case v:return oe=S._init,k(oe(S._payload),L,V,J,re)}}if(oe)return re=re(S),oe=J===""?"."+Qe(S,0):J,$(re)?(V="",oe!=null&&(V=oe.replace(Ge,"$&/")+"/"),k(re,L,V,"",function(at){return at})):re!=null&&(ae(re)&&(re=Q(re,V+(re.key==null||S&&S.key===re.key?"":(""+re.key).replace(Ge,"$&/")+"/")+oe)),L.push(re)),1;oe=0;var ze=J===""?".":J+":";if($(S))for(var Ee=0;Ee<S.length;Ee++)J=S[Ee],fe=ze+Qe(J,Ee),oe+=k(J,L,V,fe,re);else if(Ee=w(S),typeof Ee=="function")for(S=Ee.call(S),Ee=0;!(J=S.next()).done;)J=J.value,fe=ze+Qe(J,Ee++),oe+=k(J,L,V,fe,re);else if(fe==="object"){if(typeof S.then=="function")return k(ce(S),L,V,J,re);throw L=String(S),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(S).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return oe}function Y(S,L,V){if(S==null)return S;var J=[],re=0;return k(S,J,"","",function(fe){return L.call(V,fe,re++)}),J}function te(S){if(S._status===-1){var L=S._result;L=L(),L.then(function(V){(S._status===0||S._status===-1)&&(S._status=1,S._result=V)},function(V){(S._status===0||S._status===-1)&&(S._status=2,S._result=V)}),S._status===-1&&(S._status=0,S._result=L)}if(S._status===1)return S._result.default;throw S._result}var he=typeof reportError=="function"?reportError:function(S){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof S=="object"&&S!==null&&typeof S.message=="string"?String(S.message):String(S),error:S});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",S);return}console.error(S)},je={map:Y,forEach:function(S,L,V){Y(S,function(){L.apply(this,arguments)},V)},count:function(S){var L=0;return Y(S,function(){L++}),L},toArray:function(S){return Y(S,function(L){return L})||[]},only:function(S){if(!ae(S))throw Error("React.Children.only expected to receive a single React element child.");return S}};return ue.Activity=y,ue.Children=je,ue.Component=U,ue.Fragment=o,ue.Profiler=f,ue.PureComponent=Z,ue.StrictMode=u,ue.Suspense=g,ue.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=G,ue.__COMPILER_RUNTIME={__proto__:null,c:function(S){return G.H.useMemoCache(S)}},ue.cache=function(S){return function(){return S.apply(null,arguments)}},ue.cacheSignal=function(){return null},ue.cloneElement=function(S,L,V){if(S==null)throw Error("The argument must be a React element, but you passed "+S+".");var J=T({},S.props),re=S.key;if(L!=null)for(fe in L.key!==void 0&&(re=""+L.key),L)!ee.call(L,fe)||fe==="key"||fe==="__self"||fe==="__source"||fe==="ref"&&L.ref===void 0||(J[fe]=L[fe]);var fe=arguments.length-2;if(fe===1)J.children=V;else if(1<fe){for(var oe=Array(fe),ze=0;ze<fe;ze++)oe[ze]=arguments[ze+2];J.children=oe}return se(S.type,re,J)},ue.createContext=function(S){return S={$$typeof:h,_currentValue:S,_currentValue2:S,_threadCount:0,Provider:null,Consumer:null},S.Provider=S,S.Consumer={$$typeof:d,_context:S},S},ue.createElement=function(S,L,V){var J,re={},fe=null;if(L!=null)for(J in L.key!==void 0&&(fe=""+L.key),L)ee.call(L,J)&&J!=="key"&&J!=="__self"&&J!=="__source"&&(re[J]=L[J]);var oe=arguments.length-2;if(oe===1)re.children=V;else if(1<oe){for(var ze=Array(oe),Ee=0;Ee<oe;Ee++)ze[Ee]=arguments[Ee+2];re.children=ze}if(S&&S.defaultProps)for(J in oe=S.defaultProps,oe)re[J]===void 0&&(re[J]=oe[J]);return se(S,fe,re)},ue.createRef=function(){return{current:null}},ue.forwardRef=function(S){return{$$typeof:p,render:S}},ue.isValidElement=ae,ue.lazy=function(S){return{$$typeof:v,_payload:{_status:-1,_result:S},_init:te}},ue.memo=function(S,L){return{$$typeof:x,type:S,compare:L===void 0?null:L}},ue.startTransition=function(S){var L=G.T,V={};G.T=V;try{var J=S(),re=G.S;re!==null&&re(V,J),typeof J=="object"&&J!==null&&typeof J.then=="function"&&J.then(K,he)}catch(fe){he(fe)}finally{L!==null&&V.types!==null&&(L.types=V.types),G.T=L}},ue.unstable_useCacheRefresh=function(){return G.H.useCacheRefresh()},ue.use=function(S){return G.H.use(S)},ue.useActionState=function(S,L,V){return G.H.useActionState(S,L,V)},ue.useCallback=function(S,L){return G.H.useCallback(S,L)},ue.useContext=function(S){return G.H.useContext(S)},ue.useDebugValue=function(){},ue.useDeferredValue=function(S,L){return G.H.useDeferredValue(S,L)},ue.useEffect=function(S,L){return G.H.useEffect(S,L)},ue.useEffectEvent=function(S){return G.H.useEffectEvent(S)},ue.useId=function(){return G.H.useId()},ue.useImperativeHandle=function(S,L,V){return G.H.useImperativeHandle(S,L,V)},ue.useInsertionEffect=function(S,L){return G.H.useInsertionEffect(S,L)},ue.useLayoutEffect=function(S,L){return G.H.useLayoutEffect(S,L)},ue.useMemo=function(S,L){return G.H.useMemo(S,L)},ue.useOptimistic=function(S,L){return G.H.useOptimistic(S,L)},ue.useReducer=function(S,L,V){return G.H.useReducer(S,L,V)},ue.useRef=function(S){return G.H.useRef(S)},ue.useState=function(S){return G.H.useState(S)},ue.useSyncExternalStore=function(S,L,V){return G.H.useSyncExternalStore(S,L,V)},ue.useTransition=function(){return G.H.useTransition()},ue.version="19.2.8",ue}var yh;function Fo(){return yh||(yh=1,ko.exports=nb()),ko.exports}var q=Fo(),Oo={exports:{}},Es={},Do={exports:{}},Ro={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jh;function sb(){return jh||(jh=1,(function(i){function c(k,Y){var te=k.length;k.push(Y);e:for(;0<te;){var he=te-1>>>1,je=k[he];if(0<f(je,Y))k[he]=Y,k[te]=je,te=he;else break e}}function o(k){return k.length===0?null:k[0]}function u(k){if(k.length===0)return null;var Y=k[0],te=k.pop();if(te!==Y){k[0]=te;e:for(var he=0,je=k.length,S=je>>>1;he<S;){var L=2*(he+1)-1,V=k[L],J=L+1,re=k[J];if(0>f(V,te))J<je&&0>f(re,V)?(k[he]=re,k[J]=te,he=J):(k[he]=V,k[L]=te,he=L);else if(J<je&&0>f(re,te))k[he]=re,k[J]=te,he=J;else break e}}return Y}function f(k,Y){var te=k.sortIndex-Y.sortIndex;return te!==0?te:k.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;i.unstable_now=function(){return d.now()}}else{var h=Date,p=h.now();i.unstable_now=function(){return h.now()-p}}var g=[],x=[],v=1,y=null,z=3,w=!1,N=!1,T=!1,D=!1,U=typeof setTimeout=="function"?setTimeout:null,X=typeof clearTimeout=="function"?clearTimeout:null,Z=typeof setImmediate<"u"?setImmediate:null;function P(k){for(var Y=o(x);Y!==null;){if(Y.callback===null)u(x);else if(Y.startTime<=k)u(x),Y.sortIndex=Y.expirationTime,c(g,Y);else break;Y=o(x)}}function $(k){if(T=!1,P(k),!N)if(o(g)!==null)N=!0,K||(K=!0,ye());else{var Y=o(x);Y!==null&&ce($,Y.startTime-k)}}var K=!1,G=-1,ee=5,se=-1;function Q(){return D?!0:!(i.unstable_now()-se<ee)}function ae(){if(D=!1,K){var k=i.unstable_now();se=k;var Y=!0;try{e:{N=!1,T&&(T=!1,X(G),G=-1),w=!0;var te=z;try{t:{for(P(k),y=o(g);y!==null&&!(y.expirationTime>k&&Q());){var he=y.callback;if(typeof he=="function"){y.callback=null,z=y.priorityLevel;var je=he(y.expirationTime<=k);if(k=i.unstable_now(),typeof je=="function"){y.callback=je,P(k),Y=!0;break t}y===o(g)&&u(g),P(k)}else u(g);y=o(g)}if(y!==null)Y=!0;else{var S=o(x);S!==null&&ce($,S.startTime-k),Y=!1}}break e}finally{y=null,z=te,w=!1}Y=void 0}}finally{Y?ye():K=!1}}}var ye;if(typeof Z=="function")ye=function(){Z(ae)};else if(typeof MessageChannel<"u"){var Ge=new MessageChannel,Qe=Ge.port2;Ge.port1.onmessage=ae,ye=function(){Qe.postMessage(null)}}else ye=function(){U(ae,0)};function ce(k,Y){G=U(function(){k(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(k){k.callback=null},i.unstable_forceFrameRate=function(k){0>k||125<k?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):ee=0<k?Math.floor(1e3/k):5},i.unstable_getCurrentPriorityLevel=function(){return z},i.unstable_next=function(k){switch(z){case 1:case 2:case 3:var Y=3;break;default:Y=z}var te=z;z=Y;try{return k()}finally{z=te}},i.unstable_requestPaint=function(){D=!0},i.unstable_runWithPriority=function(k,Y){switch(k){case 1:case 2:case 3:case 4:case 5:break;default:k=3}var te=z;z=k;try{return Y()}finally{z=te}},i.unstable_scheduleCallback=function(k,Y,te){var he=i.unstable_now();switch(typeof te=="object"&&te!==null?(te=te.delay,te=typeof te=="number"&&0<te?he+te:he):te=he,k){case 1:var je=-1;break;case 2:je=250;break;case 5:je=1073741823;break;case 4:je=1e4;break;default:je=5e3}return je=te+je,k={id:v++,callback:Y,priorityLevel:k,startTime:te,expirationTime:je,sortIndex:-1},te>he?(k.sortIndex=te,c(x,k),o(g)===null&&k===o(x)&&(T?(X(G),G=-1):T=!0,ce($,te-he))):(k.sortIndex=je,c(g,k),N||w||(N=!0,K||(K=!0,ye()))),k},i.unstable_shouldYield=Q,i.unstable_wrapCallback=function(k){var Y=z;return function(){var te=z;z=Y;try{return k.apply(this,arguments)}finally{z=te}}}})(Ro)),Ro}var Nh;function ib(){return Nh||(Nh=1,Do.exports=sb()),Do.exports}var Uo={exports:{}},mt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sh;function cb(){if(Sh)return mt;Sh=1;var i=Fo();function c(g){var x="https://react.dev/errors/"+g;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var v=2;v<arguments.length;v++)x+="&args[]="+encodeURIComponent(arguments[v])}return"Minified React error #"+g+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function o(){}var u={d:{f:o,r:function(){throw Error(c(522))},D:o,C:o,L:o,m:o,X:o,S:o,M:o},p:0,findDOMNode:null},f=Symbol.for("react.portal");function d(g,x,v){var y=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:f,key:y==null?null:""+y,children:g,containerInfo:x,implementation:v}}var h=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function p(g,x){if(g==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return mt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=u,mt.createPortal=function(g,x){var v=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(c(299));return d(g,x,null,v)},mt.flushSync=function(g){var x=h.T,v=u.p;try{if(h.T=null,u.p=2,g)return g()}finally{h.T=x,u.p=v,u.d.f()}},mt.preconnect=function(g,x){typeof g=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,u.d.C(g,x))},mt.prefetchDNS=function(g){typeof g=="string"&&u.d.D(g)},mt.preinit=function(g,x){if(typeof g=="string"&&x&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin),z=typeof x.integrity=="string"?x.integrity:void 0,w=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;v==="style"?u.d.S(g,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:y,integrity:z,fetchPriority:w}):v==="script"&&u.d.X(g,{crossOrigin:y,integrity:z,fetchPriority:w,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},mt.preinitModule=function(g,x){if(typeof g=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var v=p(x.as,x.crossOrigin);u.d.M(g,{crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0})}}else x==null&&u.d.M(g)},mt.preload=function(g,x){if(typeof g=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var v=x.as,y=p(v,x.crossOrigin);u.d.L(g,v,{crossOrigin:y,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},mt.preloadModule=function(g,x){if(typeof g=="string")if(x){var v=p(x.as,x.crossOrigin);u.d.m(g,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:v,integrity:typeof x.integrity=="string"?x.integrity:void 0})}else u.d.m(g)},mt.requestFormReset=function(g){u.d.r(g)},mt.unstable_batchedUpdates=function(g,x){return g(x)},mt.useFormState=function(g,x,v){return h.H.useFormState(g,x,v)},mt.useFormStatus=function(){return h.H.useHostTransitionStatus()},mt.version="19.2.8",mt}var zh;function rb(){if(zh)return Uo.exports;zh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Uo.exports=cb(),Uo.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Eh;function ob(){if(Eh)return Es;Eh=1;var i=ib(),c=Fo(),o=rb();function u(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)t+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function f(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function d(e){var t=e,l=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do t=e,(t.flags&4098)!==0&&(l=t.return),e=t.return;while(e)}return t.tag===3?l:null}function h(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function p(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function g(e){if(d(e)!==e)throw Error(u(188))}function x(e){var t=e.alternate;if(!t){if(t=d(e),t===null)throw Error(u(188));return t!==e?null:e}for(var l=e,a=t;;){var s=l.return;if(s===null)break;var r=s.alternate;if(r===null){if(a=s.return,a!==null){l=a;continue}break}if(s.child===r.child){for(r=s.child;r;){if(r===l)return g(s),e;if(r===a)return g(s),t;r=r.sibling}throw Error(u(188))}if(l.return!==a.return)l=s,a=r;else{for(var m=!1,b=s.child;b;){if(b===l){m=!0,l=s,a=r;break}if(b===a){m=!0,a=s,l=r;break}b=b.sibling}if(!m){for(b=r.child;b;){if(b===l){m=!0,l=r,a=s;break}if(b===a){m=!0,a=r,l=s;break}b=b.sibling}if(!m)throw Error(u(189))}}if(l.alternate!==a)throw Error(u(190))}if(l.tag!==3)throw Error(u(188));return l.stateNode.current===l?e:t}function v(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=v(e),t!==null)return t;e=e.sibling}return null}var y=Object.assign,z=Symbol.for("react.element"),w=Symbol.for("react.transitional.element"),N=Symbol.for("react.portal"),T=Symbol.for("react.fragment"),D=Symbol.for("react.strict_mode"),U=Symbol.for("react.profiler"),X=Symbol.for("react.consumer"),Z=Symbol.for("react.context"),P=Symbol.for("react.forward_ref"),$=Symbol.for("react.suspense"),K=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),ee=Symbol.for("react.lazy"),se=Symbol.for("react.activity"),Q=Symbol.for("react.memo_cache_sentinel"),ae=Symbol.iterator;function ye(e){return e===null||typeof e!="object"?null:(e=ae&&e[ae]||e["@@iterator"],typeof e=="function"?e:null)}var Ge=Symbol.for("react.client.reference");function Qe(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===Ge?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case T:return"Fragment";case U:return"Profiler";case D:return"StrictMode";case $:return"Suspense";case K:return"SuspenseList";case se:return"Activity"}if(typeof e=="object")switch(e.$$typeof){case N:return"Portal";case Z:return e.displayName||"Context";case X:return(e._context.displayName||"Context")+".Consumer";case P:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case G:return t=e.displayName||null,t!==null?t:Qe(e.type)||"Memo";case ee:t=e._payload,e=e._init;try{return Qe(e(t))}catch{}}return null}var ce=Array.isArray,k=c.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Y=o.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,te={pending:!1,data:null,method:null,action:null},he=[],je=-1;function S(e){return{current:e}}function L(e){0>je||(e.current=he[je],he[je]=null,je--)}function V(e,t){je++,he[je]=e.current,e.current=t}var J=S(null),re=S(null),fe=S(null),oe=S(null);function ze(e,t){switch(V(fe,t),V(re,e),V(J,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Bm(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Bm(t),e=qm(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}L(J),V(J,e)}function Ee(){L(J),L(re),L(fe)}function at(e){e.memoizedState!==null&&V(oe,e);var t=J.current,l=qm(t,e.type);t!==l&&(V(re,e),V(J,l))}function Me(e){re.current===e&&(L(J),L(re)),oe.current===e&&(L(oe),ys._currentValue=te)}var I,_e;function ke(e){if(I===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);I=t&&t[1]||"",_e=-1<l.stack.indexOf(` + at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return` +`+I+e+_e}var tt=!1;function yt(e,t){if(!e||tt)return"";tt=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var B=function(){throw Error()};if(Object.defineProperty(B.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(B,[])}catch(O){var M=O}Reflect.construct(e,[],B)}else{try{B.call()}catch(O){M=O}e.call(B.prototype)}}else{try{throw Error()}catch(O){M=O}(B=e())&&typeof B.catch=="function"&&B.catch(function(){})}}catch(O){if(O&&M&&typeof O.stack=="string")return[O.stack,M.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var s=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");s&&s.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var r=a.DetermineComponentFrameRoot(),m=r[0],b=r[1];if(m&&b){var j=m.split(` +`),C=b.split(` +`);for(s=a=0;a<j.length&&!j[a].includes("DetermineComponentFrameRoot");)a++;for(;s<C.length&&!C[s].includes("DetermineComponentFrameRoot");)s++;if(a===j.length||s===C.length)for(a=j.length-1,s=C.length-1;1<=a&&0<=s&&j[a]!==C[s];)s--;for(;1<=a&&0<=s;a--,s--)if(j[a]!==C[s]){if(a!==1||s!==1)do if(a--,s--,0>s||j[a]!==C[s]){var R=` +`+j[a].replace(" at new "," at ");return e.displayName&&R.includes("<anonymous>")&&(R=R.replace("<anonymous>",e.displayName)),R}while(1<=a&&0<=s);break}}}finally{tt=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?ke(l):""}function Jt(e,t){switch(e.tag){case 26:case 27:case 5:return ke(e.type);case 16:return ke("Lazy");case 13:return e.child!==t&&t!==null?ke("Suspense Fallback"):ke("Suspense");case 19:return ke("SuspenseList");case 0:case 15:return yt(e.type,!1);case 11:return yt(e.type.render,!1);case 1:return yt(e.type,!0);case 31:return ke("Activity");default:return""}}function ll(e){try{var t="",l=null;do t+=Jt(e,l),l=e,e=e.return;while(e);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ht=Object.prototype.hasOwnProperty,Wt=i.unstable_scheduleCallback,hl=i.unstable_cancelCallback,Tn=i.unstable_shouldYield,An=i.unstable_requestPaint,dt=i.unstable_now,$s=i.unstable_getCurrentPriorityLevel,Qs=i.unstable_ImmediatePriority,Cn=i.unstable_UserBlockingPriority,Da=i.unstable_NormalPriority,Mn=i.unstable_LowPriority,pl=i.unstable_IdlePriority,kn=i.log,On=i.unstable_setDisableYieldValue,al=null,pt=null;function nl(e){if(typeof kn=="function"&&On(e),pt&&typeof pt.setStrictMode=="function")try{pt.setStrictMode(al,e)}catch{}}var xt=Math.clz32?Math.clz32:Xs,xc=Math.log,gc=Math.LN2;function Xs(e){return e>>>=0,e===0?32:31-(xc(e)/gc|0)|0}var Ra=256,da=262144,ma=4194304;function sl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ua(e,t,l){var a=e.pendingLanes;if(a===0)return 0;var s=0,r=e.suspendedLanes,m=e.pingedLanes;e=e.warmLanes;var b=a&134217727;return b!==0?(a=b&~r,a!==0?s=sl(a):(m&=b,m!==0?s=sl(m):l||(l=b&~e,l!==0&&(s=sl(l))))):(b=a&~r,b!==0?s=sl(b):m!==0?s=sl(m):l||(l=a&~e,l!==0&&(s=sl(l)))),s===0?0:t!==0&&t!==s&&(t&r)===0&&(r=s&-s,l=t&-t,r>=l||r===32&&(l&4194048)!==0)?t:s}function Ul(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Vs(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Zs(){var e=ma;return ma<<=1,(ma&62914560)===0&&(ma=4194304),e}function bc(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function Dn(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Yp(e,t,l,a,s,r){var m=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var b=e.entanglements,j=e.expirationTimes,C=e.hiddenUpdates;for(l=m&~l;0<l;){var R=31-xt(l),B=1<<R;b[R]=0,j[R]=-1;var M=C[R];if(M!==null)for(C[R]=null,R=0;R<M.length;R++){var O=M[R];O!==null&&(O.lane&=-536870913)}l&=~B}a!==0&&yu(e,a,0),r!==0&&s===0&&e.tag!==0&&(e.suspendedLanes|=r&~(m&~t))}function yu(e,t,l){e.pendingLanes|=t,e.suspendedLanes&=~t;var a=31-xt(t);e.entangledLanes|=t,e.entanglements[a]=e.entanglements[a]|1073741824|l&261930}function ju(e,t){var l=e.entangledLanes|=t;for(e=e.entanglements;l;){var a=31-xt(l),s=1<<a;s&t|e[a]&t&&(e[a]|=t),l&=~s}}function Nu(e,t){var l=t&-t;return l=(l&42)!==0?1:vc(l),(l&(e.suspendedLanes|t))!==0?0:l}function vc(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function yc(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function Su(){var e=Y.p;return e!==0?e:(e=window.event,e===void 0?32:oh(e.type))}function zu(e,t){var l=Y.p;try{return Y.p=e,t()}finally{Y.p=l}}var Hl=Math.random().toString(36).slice(2),it="__reactFiber$"+Hl,jt="__reactProps$"+Hl,Ha="__reactContainer$"+Hl,jc="__reactEvents$"+Hl,Gp="__reactListeners$"+Hl,$p="__reactHandles$"+Hl,Eu="__reactResources$"+Hl,Rn="__reactMarker$"+Hl;function Nc(e){delete e[it],delete e[jt],delete e[jc],delete e[Gp],delete e[$p]}function La(e){var t=e[it];if(t)return t;for(var l=e.parentNode;l;){if(t=l[Ha]||l[it]){if(l=t.alternate,t.child!==null||l!==null&&l.child!==null)for(e=Zm(e);e!==null;){if(l=e[it])return l;e=Zm(e)}return t}e=l,l=e.parentNode}return null}function Ba(e){if(e=e[it]||e[Ha]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Un(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(u(33))}function qa(e){var t=e[Eu];return t||(t=e[Eu]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function nt(e){e[Rn]=!0}var wu=new Set,_u={};function ha(e,t){Ya(e,t),Ya(e+"Capture",t)}function Ya(e,t){for(_u[e]=t,e=0;e<t.length;e++)wu.add(t[e])}var Qp=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$"),Tu={},Au={};function Xp(e){return Ht.call(Au,e)?!0:Ht.call(Tu,e)?!1:Qp.test(e)?Au[e]=!0:(Tu[e]=!0,!1)}function Ks(e,t,l){if(Xp(t))if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":e.removeAttribute(t);return;case"boolean":var a=t.toLowerCase().slice(0,5);if(a!=="data-"&&a!=="aria-"){e.removeAttribute(t);return}}e.setAttribute(t,""+l)}}function Js(e,t,l){if(l===null)e.removeAttribute(t);else{switch(typeof l){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(t);return}e.setAttribute(t,""+l)}}function xl(e,t,l,a){if(a===null)e.removeAttribute(l);else{switch(typeof a){case"undefined":case"function":case"symbol":case"boolean":e.removeAttribute(l);return}e.setAttributeNS(t,l,""+a)}}function Lt(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Cu(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Vp(e,t,l){var a=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);if(!e.hasOwnProperty(t)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var s=a.get,r=a.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return s.call(this)},set:function(m){l=""+m,r.call(this,m)}}),Object.defineProperty(e,t,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(m){l=""+m},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Sc(e){if(!e._valueTracker){var t=Cu(e)?"checked":"value";e._valueTracker=Vp(e,t,""+e[t])}}function Mu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var l=t.getValue(),a="";return e&&(a=Cu(e)?e.checked?"true":"false":e.value),e=a,e!==l?(t.setValue(e),!0):!1}function Ws(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Zp=/[\n"\\]/g;function Bt(e){return e.replace(Zp,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function zc(e,t,l,a,s,r,m,b){e.name="",m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"?e.type=m:e.removeAttribute("type"),t!=null?m==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Lt(t)):e.value!==""+Lt(t)&&(e.value=""+Lt(t)):m!=="submit"&&m!=="reset"||e.removeAttribute("value"),t!=null?Ec(e,m,Lt(t)):l!=null?Ec(e,m,Lt(l)):a!=null&&e.removeAttribute("value"),s==null&&r!=null&&(e.defaultChecked=!!r),s!=null&&(e.checked=s&&typeof s!="function"&&typeof s!="symbol"),b!=null&&typeof b!="function"&&typeof b!="symbol"&&typeof b!="boolean"?e.name=""+Lt(b):e.removeAttribute("name")}function ku(e,t,l,a,s,r,m,b){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(e.type=r),t!=null||l!=null){if(!(r!=="submit"&&r!=="reset"||t!=null)){Sc(e);return}l=l!=null?""+Lt(l):"",t=t!=null?""+Lt(t):l,b||t===e.value||(e.value=t),e.defaultValue=t}a=a??s,a=typeof a!="function"&&typeof a!="symbol"&&!!a,e.checked=b?e.checked:!!a,e.defaultChecked=!!a,m!=null&&typeof m!="function"&&typeof m!="symbol"&&typeof m!="boolean"&&(e.name=m),Sc(e)}function Ec(e,t,l){t==="number"&&Ws(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function Ga(e,t,l,a){if(e=e.options,t){t={};for(var s=0;s<l.length;s++)t["$"+l[s]]=!0;for(l=0;l<e.length;l++)s=t.hasOwnProperty("$"+e[l].value),e[l].selected!==s&&(e[l].selected=s),s&&a&&(e[l].defaultSelected=!0)}else{for(l=""+Lt(l),t=null,s=0;s<e.length;s++){if(e[s].value===l){e[s].selected=!0,a&&(e[s].defaultSelected=!0);return}t!==null||e[s].disabled||(t=e[s])}t!==null&&(t.selected=!0)}}function Ou(e,t,l){if(t!=null&&(t=""+Lt(t),t!==e.value&&(e.value=t),l==null)){e.defaultValue!==t&&(e.defaultValue=t);return}e.defaultValue=l!=null?""+Lt(l):""}function Du(e,t,l,a){if(t==null){if(a!=null){if(l!=null)throw Error(u(92));if(ce(a)){if(1<a.length)throw Error(u(93));a=a[0]}l=a}l==null&&(l=""),t=l}l=Lt(t),e.defaultValue=l,a=e.textContent,a===l&&a!==""&&a!==null&&(e.value=a),Sc(e)}function $a(e,t){if(t){var l=e.firstChild;if(l&&l===e.lastChild&&l.nodeType===3){l.nodeValue=t;return}}e.textContent=t}var Kp=new Set("animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp".split(" "));function Ru(e,t,l){var a=t.indexOf("--")===0;l==null||typeof l=="boolean"||l===""?a?e.setProperty(t,""):t==="float"?e.cssFloat="":e[t]="":a?e.setProperty(t,l):typeof l!="number"||l===0||Kp.has(t)?t==="float"?e.cssFloat=l:e[t]=(""+l).trim():e[t]=l+"px"}function Uu(e,t,l){if(t!=null&&typeof t!="object")throw Error(u(62));if(e=e.style,l!=null){for(var a in l)!l.hasOwnProperty(a)||t!=null&&t.hasOwnProperty(a)||(a.indexOf("--")===0?e.setProperty(a,""):a==="float"?e.cssFloat="":e[a]="");for(var s in t)a=t[s],t.hasOwnProperty(s)&&l[s]!==a&&Ru(e,s,a)}else for(var r in t)t.hasOwnProperty(r)&&Ru(e,r,t[r])}function wc(e){if(e.indexOf("-")===-1)return!1;switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Jp=new Map([["acceptCharset","accept-charset"],["htmlFor","for"],["httpEquiv","http-equiv"],["crossOrigin","crossorigin"],["accentHeight","accent-height"],["alignmentBaseline","alignment-baseline"],["arabicForm","arabic-form"],["baselineShift","baseline-shift"],["capHeight","cap-height"],["clipPath","clip-path"],["clipRule","clip-rule"],["colorInterpolation","color-interpolation"],["colorInterpolationFilters","color-interpolation-filters"],["colorProfile","color-profile"],["colorRendering","color-rendering"],["dominantBaseline","dominant-baseline"],["enableBackground","enable-background"],["fillOpacity","fill-opacity"],["fillRule","fill-rule"],["floodColor","flood-color"],["floodOpacity","flood-opacity"],["fontFamily","font-family"],["fontSize","font-size"],["fontSizeAdjust","font-size-adjust"],["fontStretch","font-stretch"],["fontStyle","font-style"],["fontVariant","font-variant"],["fontWeight","font-weight"],["glyphName","glyph-name"],["glyphOrientationHorizontal","glyph-orientation-horizontal"],["glyphOrientationVertical","glyph-orientation-vertical"],["horizAdvX","horiz-adv-x"],["horizOriginX","horiz-origin-x"],["imageRendering","image-rendering"],["letterSpacing","letter-spacing"],["lightingColor","lighting-color"],["markerEnd","marker-end"],["markerMid","marker-mid"],["markerStart","marker-start"],["overlinePosition","overline-position"],["overlineThickness","overline-thickness"],["paintOrder","paint-order"],["panose-1","panose-1"],["pointerEvents","pointer-events"],["renderingIntent","rendering-intent"],["shapeRendering","shape-rendering"],["stopColor","stop-color"],["stopOpacity","stop-opacity"],["strikethroughPosition","strikethrough-position"],["strikethroughThickness","strikethrough-thickness"],["strokeDasharray","stroke-dasharray"],["strokeDashoffset","stroke-dashoffset"],["strokeLinecap","stroke-linecap"],["strokeLinejoin","stroke-linejoin"],["strokeMiterlimit","stroke-miterlimit"],["strokeOpacity","stroke-opacity"],["strokeWidth","stroke-width"],["textAnchor","text-anchor"],["textDecoration","text-decoration"],["textRendering","text-rendering"],["transformOrigin","transform-origin"],["underlinePosition","underline-position"],["underlineThickness","underline-thickness"],["unicodeBidi","unicode-bidi"],["unicodeRange","unicode-range"],["unitsPerEm","units-per-em"],["vAlphabetic","v-alphabetic"],["vHanging","v-hanging"],["vIdeographic","v-ideographic"],["vMathematical","v-mathematical"],["vectorEffect","vector-effect"],["vertAdvY","vert-adv-y"],["vertOriginX","vert-origin-x"],["vertOriginY","vert-origin-y"],["wordSpacing","word-spacing"],["writingMode","writing-mode"],["xmlnsXlink","xmlns:xlink"],["xHeight","x-height"]]),Wp=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;function Fs(e){return Wp.test(""+e)?"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')":e}function gl(){}var _c=null;function Tc(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Qa=null,Xa=null;function Hu(e){var t=Ba(e);if(t&&(e=t.stateNode)){var l=e[jt]||null;e:switch(e=t.stateNode,t.type){case"input":if(zc(e,l.value,l.defaultValue,l.defaultValue,l.checked,l.defaultChecked,l.type,l.name),t=l.name,l.type==="radio"&&t!=null){for(l=e;l.parentNode;)l=l.parentNode;for(l=l.querySelectorAll('input[name="'+Bt(""+t)+'"][type="radio"]'),t=0;t<l.length;t++){var a=l[t];if(a!==e&&a.form===e.form){var s=a[jt]||null;if(!s)throw Error(u(90));zc(a,s.value,s.defaultValue,s.defaultValue,s.checked,s.defaultChecked,s.type,s.name)}}for(t=0;t<l.length;t++)a=l[t],a.form===e.form&&Mu(a)}break e;case"textarea":Ou(e,l.value,l.defaultValue);break e;case"select":t=l.value,t!=null&&Ga(e,!!l.multiple,t,!1)}}}var Ac=!1;function Lu(e,t,l){if(Ac)return e(t,l);Ac=!0;try{var a=e(t);return a}finally{if(Ac=!1,(Qa!==null||Xa!==null)&&(Li(),Qa&&(t=Qa,e=Xa,Xa=Qa=null,Hu(t),e)))for(t=0;t<e.length;t++)Hu(e[t])}}function Hn(e,t){var l=e.stateNode;if(l===null)return null;var a=l[jt]||null;if(a===null)return null;l=a[t];e:switch(t){case"onClick":case"onClickCapture":case"onDoubleClick":case"onDoubleClickCapture":case"onMouseDown":case"onMouseDownCapture":case"onMouseMove":case"onMouseMoveCapture":case"onMouseUp":case"onMouseUpCapture":case"onMouseEnter":(a=!a.disabled)||(e=e.type,a=!(e==="button"||e==="input"||e==="select"||e==="textarea")),e=!a;break e;default:e=!1}if(e)return null;if(l&&typeof l!="function")throw Error(u(231,t,typeof l));return l}var bl=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Cc=!1;if(bl)try{var Ln={};Object.defineProperty(Ln,"passive",{get:function(){Cc=!0}}),window.addEventListener("test",Ln,Ln),window.removeEventListener("test",Ln,Ln)}catch{Cc=!1}var Ll=null,Mc=null,Is=null;function Bu(){if(Is)return Is;var e,t=Mc,l=t.length,a,s="value"in Ll?Ll.value:Ll.textContent,r=s.length;for(e=0;e<l&&t[e]===s[e];e++);var m=l-e;for(a=1;a<=m&&t[l-a]===s[r-a];a++);return Is=s.slice(e,1<a?1-a:void 0)}function Ps(e){var t=e.keyCode;return"charCode"in e?(e=e.charCode,e===0&&t===13&&(e=13)):e=t,e===10&&(e=13),32<=e||e===13?e:0}function ei(){return!0}function qu(){return!1}function Nt(e){function t(l,a,s,r,m){this._reactName=l,this._targetInst=s,this.type=a,this.nativeEvent=r,this.target=m,this.currentTarget=null;for(var b in e)e.hasOwnProperty(b)&&(l=e[b],this[b]=l?l(r):r[b]);return this.isDefaultPrevented=(r.defaultPrevented!=null?r.defaultPrevented:r.returnValue===!1)?ei:qu,this.isPropagationStopped=qu,this}return y(t.prototype,{preventDefault:function(){this.defaultPrevented=!0;var l=this.nativeEvent;l&&(l.preventDefault?l.preventDefault():typeof l.returnValue!="unknown"&&(l.returnValue=!1),this.isDefaultPrevented=ei)},stopPropagation:function(){var l=this.nativeEvent;l&&(l.stopPropagation?l.stopPropagation():typeof l.cancelBubble!="unknown"&&(l.cancelBubble=!0),this.isPropagationStopped=ei)},persist:function(){},isPersistent:ei}),t}var pa={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},ti=Nt(pa),Bn=y({},pa,{view:0,detail:0}),Fp=Nt(Bn),kc,Oc,qn,li=y({},Bn,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Rc,button:0,buttons:0,relatedTarget:function(e){return e.relatedTarget===void 0?e.fromElement===e.srcElement?e.toElement:e.fromElement:e.relatedTarget},movementX:function(e){return"movementX"in e?e.movementX:(e!==qn&&(qn&&e.type==="mousemove"?(kc=e.screenX-qn.screenX,Oc=e.screenY-qn.screenY):Oc=kc=0,qn=e),kc)},movementY:function(e){return"movementY"in e?e.movementY:Oc}}),Yu=Nt(li),Ip=y({},li,{dataTransfer:0}),Pp=Nt(Ip),ex=y({},Bn,{relatedTarget:0}),Dc=Nt(ex),tx=y({},pa,{animationName:0,elapsedTime:0,pseudoElement:0}),lx=Nt(tx),ax=y({},pa,{clipboardData:function(e){return"clipboardData"in e?e.clipboardData:window.clipboardData}}),nx=Nt(ax),sx=y({},pa,{data:0}),Gu=Nt(sx),ix={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},cx={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},rx={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"};function ox(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):(e=rx[e])?!!t[e]:!1}function Rc(){return ox}var ux=y({},Bn,{key:function(e){if(e.key){var t=ix[e.key]||e.key;if(t!=="Unidentified")return t}return e.type==="keypress"?(e=Ps(e),e===13?"Enter":String.fromCharCode(e)):e.type==="keydown"||e.type==="keyup"?cx[e.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:Rc,charCode:function(e){return e.type==="keypress"?Ps(e):0},keyCode:function(e){return e.type==="keydown"||e.type==="keyup"?e.keyCode:0},which:function(e){return e.type==="keypress"?Ps(e):e.type==="keydown"||e.type==="keyup"?e.keyCode:0}}),fx=Nt(ux),dx=y({},li,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),$u=Nt(dx),mx=y({},Bn,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:Rc}),hx=Nt(mx),px=y({},pa,{propertyName:0,elapsedTime:0,pseudoElement:0}),xx=Nt(px),gx=y({},li,{deltaX:function(e){return"deltaX"in e?e.deltaX:"wheelDeltaX"in e?-e.wheelDeltaX:0},deltaY:function(e){return"deltaY"in e?e.deltaY:"wheelDeltaY"in e?-e.wheelDeltaY:"wheelDelta"in e?-e.wheelDelta:0},deltaZ:0,deltaMode:0}),bx=Nt(gx),vx=y({},pa,{newState:0,oldState:0}),yx=Nt(vx),jx=[9,13,27,32],Uc=bl&&"CompositionEvent"in window,Yn=null;bl&&"documentMode"in document&&(Yn=document.documentMode);var Nx=bl&&"TextEvent"in window&&!Yn,Qu=bl&&(!Uc||Yn&&8<Yn&&11>=Yn),Xu=" ",Vu=!1;function Zu(e,t){switch(e){case"keyup":return jx.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ku(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Va=!1;function Sx(e,t){switch(e){case"compositionend":return Ku(t);case"keypress":return t.which!==32?null:(Vu=!0,Xu);case"textInput":return e=t.data,e===Xu&&Vu?null:e;default:return null}}function zx(e,t){if(Va)return e==="compositionend"||!Uc&&Zu(e,t)?(e=Bu(),Is=Mc=Ll=null,Va=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case"compositionend":return Qu&&t.locale!=="ko"?null:t.data;default:return null}}var Ex={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Ju(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t==="input"?!!Ex[e.type]:t==="textarea"}function Wu(e,t,l,a){Qa?Xa?Xa.push(a):Xa=[a]:Qa=a,t=Xi(t,"onChange"),0<t.length&&(l=new ti("onChange","change",null,l,a),e.push({event:l,listeners:t}))}var Gn=null,$n=null;function wx(e){Om(e,0)}function ai(e){var t=Un(e);if(Mu(t))return e}function Fu(e,t){if(e==="change")return t}var Iu=!1;if(bl){var Hc;if(bl){var Lc="oninput"in document;if(!Lc){var Pu=document.createElement("div");Pu.setAttribute("oninput","return;"),Lc=typeof Pu.oninput=="function"}Hc=Lc}else Hc=!1;Iu=Hc&&(!document.documentMode||9<document.documentMode)}function ef(){Gn&&(Gn.detachEvent("onpropertychange",tf),$n=Gn=null)}function tf(e){if(e.propertyName==="value"&&ai($n)){var t=[];Wu(t,$n,e,Tc(e)),Lu(wx,t)}}function _x(e,t,l){e==="focusin"?(ef(),Gn=t,$n=l,Gn.attachEvent("onpropertychange",tf)):e==="focusout"&&ef()}function Tx(e){if(e==="selectionchange"||e==="keyup"||e==="keydown")return ai($n)}function Ax(e,t){if(e==="click")return ai(t)}function Cx(e,t){if(e==="input"||e==="change")return ai(t)}function Mx(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Ct=typeof Object.is=="function"?Object.is:Mx;function Qn(e,t){if(Ct(e,t))return!0;if(typeof e!="object"||e===null||typeof t!="object"||t===null)return!1;var l=Object.keys(e),a=Object.keys(t);if(l.length!==a.length)return!1;for(a=0;a<l.length;a++){var s=l[a];if(!Ht.call(t,s)||!Ct(e[s],t[s]))return!1}return!0}function lf(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function af(e,t){var l=lf(e);e=0;for(var a;l;){if(l.nodeType===3){if(a=e+l.textContent.length,e<=t&&a>=t)return{node:l,offset:t-e};e=a}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=lf(l)}}function nf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?nf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function sf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ws(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=Ws(e.document)}return t}function Bc(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var kx=bl&&"documentMode"in document&&11>=document.documentMode,Za=null,qc=null,Xn=null,Yc=!1;function cf(e,t,l){var a=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Yc||Za==null||Za!==Ws(a)||(a=Za,"selectionStart"in a&&Bc(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Xn&&Qn(Xn,a)||(Xn=a,a=Xi(qc,"onSelect"),0<a.length&&(t=new ti("onSelect","select",null,t,l),e.push({event:t,listeners:a}),t.target=Za)))}function xa(e,t){var l={};return l[e.toLowerCase()]=t.toLowerCase(),l["Webkit"+e]="webkit"+t,l["Moz"+e]="moz"+t,l}var Ka={animationend:xa("Animation","AnimationEnd"),animationiteration:xa("Animation","AnimationIteration"),animationstart:xa("Animation","AnimationStart"),transitionrun:xa("Transition","TransitionRun"),transitionstart:xa("Transition","TransitionStart"),transitioncancel:xa("Transition","TransitionCancel"),transitionend:xa("Transition","TransitionEnd")},Gc={},rf={};bl&&(rf=document.createElement("div").style,"AnimationEvent"in window||(delete Ka.animationend.animation,delete Ka.animationiteration.animation,delete Ka.animationstart.animation),"TransitionEvent"in window||delete Ka.transitionend.transition);function ga(e){if(Gc[e])return Gc[e];if(!Ka[e])return e;var t=Ka[e],l;for(l in t)if(t.hasOwnProperty(l)&&l in rf)return Gc[e]=t[l];return e}var of=ga("animationend"),uf=ga("animationiteration"),ff=ga("animationstart"),Ox=ga("transitionrun"),Dx=ga("transitionstart"),Rx=ga("transitioncancel"),df=ga("transitionend"),mf=new Map,$c="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");$c.push("scrollEnd");function Ft(e,t){mf.set(e,t),ha(t,[e])}var ni=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)},qt=[],Ja=0,Qc=0;function si(){for(var e=Ja,t=Qc=Ja=0;t<e;){var l=qt[t];qt[t++]=null;var a=qt[t];qt[t++]=null;var s=qt[t];qt[t++]=null;var r=qt[t];if(qt[t++]=null,a!==null&&s!==null){var m=a.pending;m===null?s.next=s:(s.next=m.next,m.next=s),a.pending=s}r!==0&&hf(l,s,r)}}function ii(e,t,l,a){qt[Ja++]=e,qt[Ja++]=t,qt[Ja++]=l,qt[Ja++]=a,Qc|=a,e.lanes|=a,e=e.alternate,e!==null&&(e.lanes|=a)}function Xc(e,t,l,a){return ii(e,t,l,a),ci(e)}function ba(e,t){return ii(e,null,null,t),ci(e)}function hf(e,t,l){e.lanes|=l;var a=e.alternate;a!==null&&(a.lanes|=l);for(var s=!1,r=e.return;r!==null;)r.childLanes|=l,a=r.alternate,a!==null&&(a.childLanes|=l),r.tag===22&&(e=r.stateNode,e===null||e._visibility&1||(s=!0)),e=r,r=r.return;return e.tag===3?(r=e.stateNode,s&&t!==null&&(s=31-xt(l),e=r.hiddenUpdates,a=e[s],a===null?e[s]=[t]:a.push(t),t.lane=l|536870912),r):null}function ci(e){if(50<ms)throw ms=0,eo=null,Error(u(185));for(var t=e.return;t!==null;)e=t,t=e.return;return e.tag===3?e.stateNode:null}var Wa={};function Ux(e,t,l,a){this.tag=e,this.key=l,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Mt(e,t,l,a){return new Ux(e,t,l,a)}function Vc(e){return e=e.prototype,!(!e||!e.isReactComponent)}function vl(e,t){var l=e.alternate;return l===null?(l=Mt(e.tag,t,e.key,e.mode),l.elementType=e.elementType,l.type=e.type,l.stateNode=e.stateNode,l.alternate=e,e.alternate=l):(l.pendingProps=t,l.type=e.type,l.flags=0,l.subtreeFlags=0,l.deletions=null),l.flags=e.flags&65011712,l.childLanes=e.childLanes,l.lanes=e.lanes,l.child=e.child,l.memoizedProps=e.memoizedProps,l.memoizedState=e.memoizedState,l.updateQueue=e.updateQueue,t=e.dependencies,l.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},l.sibling=e.sibling,l.index=e.index,l.ref=e.ref,l.refCleanup=e.refCleanup,l}function pf(e,t){e.flags&=65011714;var l=e.alternate;return l===null?(e.childLanes=0,e.lanes=t,e.child=null,e.subtreeFlags=0,e.memoizedProps=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.stateNode=null):(e.childLanes=l.childLanes,e.lanes=l.lanes,e.child=l.child,e.subtreeFlags=0,e.deletions=null,e.memoizedProps=l.memoizedProps,e.memoizedState=l.memoizedState,e.updateQueue=l.updateQueue,e.type=l.type,t=l.dependencies,e.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext}),e}function ri(e,t,l,a,s,r){var m=0;if(a=e,typeof e=="function")Vc(e)&&(m=1);else if(typeof e=="string")m=Yg(e,l,J.current)?26:e==="html"||e==="head"||e==="body"?27:5;else e:switch(e){case se:return e=Mt(31,l,t,s),e.elementType=se,e.lanes=r,e;case T:return va(l.children,s,r,t);case D:m=8,s|=24;break;case U:return e=Mt(12,l,t,s|2),e.elementType=U,e.lanes=r,e;case $:return e=Mt(13,l,t,s),e.elementType=$,e.lanes=r,e;case K:return e=Mt(19,l,t,s),e.elementType=K,e.lanes=r,e;default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Z:m=10;break e;case X:m=9;break e;case P:m=11;break e;case G:m=14;break e;case ee:m=16,a=null;break e}m=29,l=Error(u(130,e===null?"null":typeof e,"")),a=null}return t=Mt(m,l,t,s),t.elementType=e,t.type=a,t.lanes=r,t}function va(e,t,l,a){return e=Mt(7,e,a,t),e.lanes=l,e}function Zc(e,t,l){return e=Mt(6,e,null,t),e.lanes=l,e}function xf(e){var t=Mt(18,null,null,0);return t.stateNode=e,t}function Kc(e,t,l){return t=Mt(4,e.children!==null?e.children:[],e.key,t),t.lanes=l,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}var gf=new WeakMap;function Yt(e,t){if(typeof e=="object"&&e!==null){var l=gf.get(e);return l!==void 0?l:(t={value:e,source:t,stack:ll(t)},gf.set(e,t),t)}return{value:e,source:t,stack:ll(t)}}var Fa=[],Ia=0,oi=null,Vn=0,Gt=[],$t=0,Bl=null,il=1,cl="";function yl(e,t){Fa[Ia++]=Vn,Fa[Ia++]=oi,oi=e,Vn=t}function bf(e,t,l){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,Bl=e;var a=il;e=cl;var s=32-xt(a)-1;a&=~(1<<s),l+=1;var r=32-xt(t)+s;if(30<r){var m=s-s%5;r=(a&(1<<m)-1).toString(32),a>>=m,s-=m,il=1<<32-xt(t)+s|l<<s|a,cl=r+e}else il=1<<r|l<<s|a,cl=e}function Jc(e){e.return!==null&&(yl(e,1),bf(e,1,0))}function Wc(e){for(;e===oi;)oi=Fa[--Ia],Fa[Ia]=null,Vn=Fa[--Ia],Fa[Ia]=null;for(;e===Bl;)Bl=Gt[--$t],Gt[$t]=null,cl=Gt[--$t],Gt[$t]=null,il=Gt[--$t],Gt[$t]=null}function vf(e,t){Gt[$t++]=il,Gt[$t++]=cl,Gt[$t++]=Bl,il=t.id,cl=t.overflow,Bl=e}var ct=null,Le=null,ve=!1,ql=null,Qt=!1,Fc=Error(u(519));function Yl(e){var t=Error(u(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?"text":"HTML",""));throw Zn(Yt(t,e)),Fc}function yf(e){var t=e.stateNode,l=e.type,a=e.memoizedProps;switch(t[it]=e,t[jt]=a,l){case"dialog":xe("cancel",t),xe("close",t);break;case"iframe":case"object":case"embed":xe("load",t);break;case"video":case"audio":for(l=0;l<ps.length;l++)xe(ps[l],t);break;case"source":xe("error",t);break;case"img":case"image":case"link":xe("error",t),xe("load",t);break;case"details":xe("toggle",t);break;case"input":xe("invalid",t),ku(t,a.value,a.defaultValue,a.checked,a.defaultChecked,a.type,a.name,!0);break;case"select":xe("invalid",t);break;case"textarea":xe("invalid",t),Du(t,a.value,a.defaultValue,a.children)}l=a.children,typeof l!="string"&&typeof l!="number"&&typeof l!="bigint"||t.textContent===""+l||a.suppressHydrationWarning===!0||Hm(t.textContent,l)?(a.popover!=null&&(xe("beforetoggle",t),xe("toggle",t)),a.onScroll!=null&&xe("scroll",t),a.onScrollEnd!=null&&xe("scrollend",t),a.onClick!=null&&(t.onclick=gl),t=!0):t=!1,t||Yl(e,!0)}function jf(e){for(ct=e.return;ct;)switch(ct.tag){case 5:case 31:case 13:Qt=!1;return;case 27:case 3:Qt=!0;return;default:ct=ct.return}}function Pa(e){if(e!==ct)return!1;if(!ve)return jf(e),ve=!0,!1;var t=e.tag,l;if((l=t!==3&&t!==27)&&((l=t===5)&&(l=e.type,l=!(l!=="form"&&l!=="button")||xo(e.type,e.memoizedProps)),l=!l),l&&Le&&Yl(e),jf(e),t===13){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else if(t===31){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(317));Le=Vm(e)}else t===27?(t=Le,ta(e.type)?(e=jo,jo=null,Le=e):Le=t):Le=ct?Vt(e.stateNode.nextSibling):null;return!0}function ya(){Le=ct=null,ve=!1}function Ic(){var e=ql;return e!==null&&(wt===null?wt=e:wt.push.apply(wt,e),ql=null),e}function Zn(e){ql===null?ql=[e]:ql.push(e)}var Pc=S(null),ja=null,jl=null;function Gl(e,t,l){V(Pc,t._currentValue),t._currentValue=l}function Nl(e){e._currentValue=Pc.current,L(Pc)}function er(e,t,l){for(;e!==null;){var a=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,a!==null&&(a.childLanes|=t)):a!==null&&(a.childLanes&t)!==t&&(a.childLanes|=t),e===l)break;e=e.return}}function tr(e,t,l,a){var s=e.child;for(s!==null&&(s.return=e);s!==null;){var r=s.dependencies;if(r!==null){var m=s.child;r=r.firstContext;e:for(;r!==null;){var b=r;r=s;for(var j=0;j<t.length;j++)if(b.context===t[j]){r.lanes|=l,b=r.alternate,b!==null&&(b.lanes|=l),er(r.return,l,e),a||(m=null);break e}r=b.next}}else if(s.tag===18){if(m=s.return,m===null)throw Error(u(341));m.lanes|=l,r=m.alternate,r!==null&&(r.lanes|=l),er(m,l,e),m=null}else m=s.child;if(m!==null)m.return=s;else for(m=s;m!==null;){if(m===e){m=null;break}if(s=m.sibling,s!==null){s.return=m.return,m=s;break}m=m.return}s=m}}function en(e,t,l,a){e=null;for(var s=t,r=!1;s!==null;){if(!r){if((s.flags&524288)!==0)r=!0;else if((s.flags&262144)!==0)break}if(s.tag===10){var m=s.alternate;if(m===null)throw Error(u(387));if(m=m.memoizedProps,m!==null){var b=s.type;Ct(s.pendingProps.value,m.value)||(e!==null?e.push(b):e=[b])}}else if(s===oe.current){if(m=s.alternate,m===null)throw Error(u(387));m.memoizedState.memoizedState!==s.memoizedState.memoizedState&&(e!==null?e.push(ys):e=[ys])}s=s.return}e!==null&&tr(t,e,l,a),t.flags|=262144}function ui(e){for(e=e.firstContext;e!==null;){if(!Ct(e.context._currentValue,e.memoizedValue))return!0;e=e.next}return!1}function Na(e){ja=e,jl=null,e=e.dependencies,e!==null&&(e.firstContext=null)}function rt(e){return Nf(ja,e)}function fi(e,t){return ja===null&&Na(e),Nf(e,t)}function Nf(e,t){var l=t._currentValue;if(t={context:t,memoizedValue:l,next:null},jl===null){if(e===null)throw Error(u(308));jl=t,e.dependencies={lanes:0,firstContext:t},e.flags|=524288}else jl=jl.next=t;return l}var Hx=typeof AbortController<"u"?AbortController:function(){var e=[],t=this.signal={aborted:!1,addEventListener:function(l,a){e.push(a)}};this.abort=function(){t.aborted=!0,e.forEach(function(l){return l()})}},Lx=i.unstable_scheduleCallback,Bx=i.unstable_NormalPriority,Fe={$$typeof:Z,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function lr(){return{controller:new Hx,data:new Map,refCount:0}}function Kn(e){e.refCount--,e.refCount===0&&Lx(Bx,function(){e.controller.abort()})}var Jn=null,ar=0,tn=0,ln=null;function qx(e,t){if(Jn===null){var l=Jn=[];ar=0,tn=io(),ln={status:"pending",value:void 0,then:function(a){l.push(a)}}}return ar++,t.then(Sf,Sf),t}function Sf(){if(--ar===0&&Jn!==null){ln!==null&&(ln.status="fulfilled");var e=Jn;Jn=null,tn=0,ln=null;for(var t=0;t<e.length;t++)(0,e[t])()}}function Yx(e,t){var l=[],a={status:"pending",value:null,reason:null,then:function(s){l.push(s)}};return e.then(function(){a.status="fulfilled",a.value=t;for(var s=0;s<l.length;s++)(0,l[s])(t)},function(s){for(a.status="rejected",a.reason=s,s=0;s<l.length;s++)(0,l[s])(void 0)}),a}var zf=k.S;k.S=function(e,t){cm=dt(),typeof t=="object"&&t!==null&&typeof t.then=="function"&&qx(e,t),zf!==null&&zf(e,t)};var Sa=S(null);function nr(){var e=Sa.current;return e!==null?e:Ue.pooledCache}function di(e,t){t===null?V(Sa,Sa.current):V(Sa,t.pool)}function Ef(){var e=nr();return e===null?null:{parent:Fe._currentValue,pool:e}}var an=Error(u(460)),sr=Error(u(474)),mi=Error(u(542)),hi={then:function(){}};function wf(e){return e=e.status,e==="fulfilled"||e==="rejected"}function _f(e,t,l){switch(l=e[l],l===void 0?e.push(t):l!==t&&(t.then(gl,gl),t=l),t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,Af(e),e;default:if(typeof t.status=="string")t.then(gl,gl);else{if(e=Ue,e!==null&&100<e.shellSuspendCounter)throw Error(u(482));e=t,e.status="pending",e.then(function(a){if(t.status==="pending"){var s=t;s.status="fulfilled",s.value=a}},function(a){if(t.status==="pending"){var s=t;s.status="rejected",s.reason=a}})}switch(t.status){case"fulfilled":return t.value;case"rejected":throw e=t.reason,Af(e),e}throw Ea=t,an}}function za(e){try{var t=e._init;return t(e._payload)}catch(l){throw l!==null&&typeof l=="object"&&typeof l.then=="function"?(Ea=l,an):l}}var Ea=null;function Tf(){if(Ea===null)throw Error(u(459));var e=Ea;return Ea=null,e}function Af(e){if(e===an||e===mi)throw Error(u(483))}var nn=null,Wn=0;function pi(e){var t=Wn;return Wn+=1,nn===null&&(nn=[]),_f(nn,e,t)}function Fn(e,t){t=t.props.ref,e.ref=t!==void 0?t:null}function xi(e,t){throw t.$$typeof===z?Error(u(525)):(e=Object.prototype.toString.call(t),Error(u(31,e==="[object Object]"?"object with keys {"+Object.keys(t).join(", ")+"}":e)))}function Cf(e){function t(_,E){if(e){var A=_.deletions;A===null?(_.deletions=[E],_.flags|=16):A.push(E)}}function l(_,E){if(!e)return null;for(;E!==null;)t(_,E),E=E.sibling;return null}function a(_){for(var E=new Map;_!==null;)_.key!==null?E.set(_.key,_):E.set(_.index,_),_=_.sibling;return E}function s(_,E){return _=vl(_,E),_.index=0,_.sibling=null,_}function r(_,E,A){return _.index=A,e?(A=_.alternate,A!==null?(A=A.index,A<E?(_.flags|=67108866,E):A):(_.flags|=67108866,E)):(_.flags|=1048576,E)}function m(_){return e&&_.alternate===null&&(_.flags|=67108866),_}function b(_,E,A,H){return E===null||E.tag!==6?(E=Zc(A,_.mode,H),E.return=_,E):(E=s(E,A),E.return=_,E)}function j(_,E,A,H){var ne=A.type;return ne===T?R(_,E,A.props.children,H,A.key):E!==null&&(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type)?(E=s(E,A.props),Fn(E,A),E.return=_,E):(E=ri(A.type,A.key,A.props,null,_.mode,H),Fn(E,A),E.return=_,E)}function C(_,E,A,H){return E===null||E.tag!==4||E.stateNode.containerInfo!==A.containerInfo||E.stateNode.implementation!==A.implementation?(E=Kc(A,_.mode,H),E.return=_,E):(E=s(E,A.children||[]),E.return=_,E)}function R(_,E,A,H,ne){return E===null||E.tag!==7?(E=va(A,_.mode,H,ne),E.return=_,E):(E=s(E,A),E.return=_,E)}function B(_,E,A){if(typeof E=="string"&&E!==""||typeof E=="number"||typeof E=="bigint")return E=Zc(""+E,_.mode,A),E.return=_,E;if(typeof E=="object"&&E!==null){switch(E.$$typeof){case w:return A=ri(E.type,E.key,E.props,null,_.mode,A),Fn(A,E),A.return=_,A;case N:return E=Kc(E,_.mode,A),E.return=_,E;case ee:return E=za(E),B(_,E,A)}if(ce(E)||ye(E))return E=va(E,_.mode,A,null),E.return=_,E;if(typeof E.then=="function")return B(_,pi(E),A);if(E.$$typeof===Z)return B(_,fi(_,E),A);xi(_,E)}return null}function M(_,E,A,H){var ne=E!==null?E.key:null;if(typeof A=="string"&&A!==""||typeof A=="number"||typeof A=="bigint")return ne!==null?null:b(_,E,""+A,H);if(typeof A=="object"&&A!==null){switch(A.$$typeof){case w:return A.key===ne?j(_,E,A,H):null;case N:return A.key===ne?C(_,E,A,H):null;case ee:return A=za(A),M(_,E,A,H)}if(ce(A)||ye(A))return ne!==null?null:R(_,E,A,H,null);if(typeof A.then=="function")return M(_,E,pi(A),H);if(A.$$typeof===Z)return M(_,E,fi(_,A),H);xi(_,A)}return null}function O(_,E,A,H,ne){if(typeof H=="string"&&H!==""||typeof H=="number"||typeof H=="bigint")return _=_.get(A)||null,b(E,_,""+H,ne);if(typeof H=="object"&&H!==null){switch(H.$$typeof){case w:return _=_.get(H.key===null?A:H.key)||null,j(E,_,H,ne);case N:return _=_.get(H.key===null?A:H.key)||null,C(E,_,H,ne);case ee:return H=za(H),O(_,E,A,H,ne)}if(ce(H)||ye(H))return _=_.get(A)||null,R(E,_,H,ne,null);if(typeof H.then=="function")return O(_,E,A,pi(H),ne);if(H.$$typeof===Z)return O(_,E,A,fi(E,H),ne);xi(E,H)}return null}function W(_,E,A,H){for(var ne=null,Ne=null,F=E,me=E=0,be=null;F!==null&&me<A.length;me++){F.index>me?(be=F,F=null):be=F.sibling;var Se=M(_,F,A[me],H);if(Se===null){F===null&&(F=be);break}e&&F&&Se.alternate===null&&t(_,F),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se,F=be}if(me===A.length)return l(_,F),ve&&yl(_,me),ne;if(F===null){for(;me<A.length;me++)F=B(_,A[me],H),F!==null&&(E=r(F,E,me),Ne===null?ne=F:Ne.sibling=F,Ne=F);return ve&&yl(_,me),ne}for(F=a(F);me<A.length;me++)be=O(F,_,me,A[me],H),be!==null&&(e&&be.alternate!==null&&F.delete(be.key===null?me:be.key),E=r(be,E,me),Ne===null?ne=be:Ne.sibling=be,Ne=be);return e&&F.forEach(function(ia){return t(_,ia)}),ve&&yl(_,me),ne}function ie(_,E,A,H){if(A==null)throw Error(u(151));for(var ne=null,Ne=null,F=E,me=E=0,be=null,Se=A.next();F!==null&&!Se.done;me++,Se=A.next()){F.index>me?(be=F,F=null):be=F.sibling;var ia=M(_,F,Se.value,H);if(ia===null){F===null&&(F=be);break}e&&F&&ia.alternate===null&&t(_,F),E=r(ia,E,me),Ne===null?ne=ia:Ne.sibling=ia,Ne=ia,F=be}if(Se.done)return l(_,F),ve&&yl(_,me),ne;if(F===null){for(;!Se.done;me++,Se=A.next())Se=B(_,Se.value,H),Se!==null&&(E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return ve&&yl(_,me),ne}for(F=a(F);!Se.done;me++,Se=A.next())Se=O(F,_,me,Se.value,H),Se!==null&&(e&&Se.alternate!==null&&F.delete(Se.key===null?me:Se.key),E=r(Se,E,me),Ne===null?ne=Se:Ne.sibling=Se,Ne=Se);return e&&F.forEach(function(Ig){return t(_,Ig)}),ve&&yl(_,me),ne}function Re(_,E,A,H){if(typeof A=="object"&&A!==null&&A.type===T&&A.key===null&&(A=A.props.children),typeof A=="object"&&A!==null){switch(A.$$typeof){case w:e:{for(var ne=A.key;E!==null;){if(E.key===ne){if(ne=A.type,ne===T){if(E.tag===7){l(_,E.sibling),H=s(E,A.props.children),H.return=_,_=H;break e}}else if(E.elementType===ne||typeof ne=="object"&&ne!==null&&ne.$$typeof===ee&&za(ne)===E.type){l(_,E.sibling),H=s(E,A.props),Fn(H,A),H.return=_,_=H;break e}l(_,E);break}else t(_,E);E=E.sibling}A.type===T?(H=va(A.props.children,_.mode,H,A.key),H.return=_,_=H):(H=ri(A.type,A.key,A.props,null,_.mode,H),Fn(H,A),H.return=_,_=H)}return m(_);case N:e:{for(ne=A.key;E!==null;){if(E.key===ne)if(E.tag===4&&E.stateNode.containerInfo===A.containerInfo&&E.stateNode.implementation===A.implementation){l(_,E.sibling),H=s(E,A.children||[]),H.return=_,_=H;break e}else{l(_,E);break}else t(_,E);E=E.sibling}H=Kc(A,_.mode,H),H.return=_,_=H}return m(_);case ee:return A=za(A),Re(_,E,A,H)}if(ce(A))return W(_,E,A,H);if(ye(A)){if(ne=ye(A),typeof ne!="function")throw Error(u(150));return A=ne.call(A),ie(_,E,A,H)}if(typeof A.then=="function")return Re(_,E,pi(A),H);if(A.$$typeof===Z)return Re(_,E,fi(_,A),H);xi(_,A)}return typeof A=="string"&&A!==""||typeof A=="number"||typeof A=="bigint"?(A=""+A,E!==null&&E.tag===6?(l(_,E.sibling),H=s(E,A),H.return=_,_=H):(l(_,E),H=Zc(A,_.mode,H),H.return=_,_=H),m(_)):l(_,E)}return function(_,E,A,H){try{Wn=0;var ne=Re(_,E,A,H);return nn=null,ne}catch(F){if(F===an||F===mi)throw F;var Ne=Mt(29,F,null,_.mode);return Ne.lanes=H,Ne.return=_,Ne}finally{}}}var wa=Cf(!0),Mf=Cf(!1),$l=!1;function ir(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function cr(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ql(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Xl(e,t,l){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(we&2)!==0){var s=a.pending;return s===null?t.next=t:(t.next=s.next,s.next=t),a.pending=t,t=ci(e),hf(e,null,l),t}return ii(e,a,t,l),ci(e)}function In(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}function rr(e,t){var l=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,l===a)){var s=null,r=null;if(l=l.firstBaseUpdate,l!==null){do{var m={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};r===null?s=r=m:r=r.next=m,l=l.next}while(l!==null);r===null?s=r=t:r=r.next=t}else s=r=t;l={baseState:a.baseState,firstBaseUpdate:s,lastBaseUpdate:r,shared:a.shared,callbacks:a.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var or=!1;function Pn(){if(or){var e=ln;if(e!==null)throw e}}function es(e,t,l,a){or=!1;var s=e.updateQueue;$l=!1;var r=s.firstBaseUpdate,m=s.lastBaseUpdate,b=s.shared.pending;if(b!==null){s.shared.pending=null;var j=b,C=j.next;j.next=null,m===null?r=C:m.next=C,m=j;var R=e.alternate;R!==null&&(R=R.updateQueue,b=R.lastBaseUpdate,b!==m&&(b===null?R.firstBaseUpdate=C:b.next=C,R.lastBaseUpdate=j))}if(r!==null){var B=s.baseState;m=0,R=C=j=null,b=r;do{var M=b.lane&-536870913,O=M!==b.lane;if(O?(ge&M)===M:(a&M)===M){M!==0&&M===tn&&(or=!0),R!==null&&(R=R.next={lane:0,tag:b.tag,payload:b.payload,callback:null,next:null});e:{var W=e,ie=b;M=t;var Re=l;switch(ie.tag){case 1:if(W=ie.payload,typeof W=="function"){B=W.call(Re,B,M);break e}B=W;break e;case 3:W.flags=W.flags&-65537|128;case 0:if(W=ie.payload,M=typeof W=="function"?W.call(Re,B,M):W,M==null)break e;B=y({},B,M);break e;case 2:$l=!0}}M=b.callback,M!==null&&(e.flags|=64,O&&(e.flags|=8192),O=s.callbacks,O===null?s.callbacks=[M]:O.push(M))}else O={lane:M,tag:b.tag,payload:b.payload,callback:b.callback,next:null},R===null?(C=R=O,j=B):R=R.next=O,m|=M;if(b=b.next,b===null){if(b=s.shared.pending,b===null)break;O=b,b=O.next,O.next=null,s.lastBaseUpdate=O,s.shared.pending=null}}while(!0);R===null&&(j=B),s.baseState=j,s.firstBaseUpdate=C,s.lastBaseUpdate=R,r===null&&(s.shared.lanes=0),Wl|=m,e.lanes=m,e.memoizedState=B}}function kf(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function Of(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;e<l.length;e++)kf(l[e],t)}var sn=S(null),gi=S(0);function Df(e,t){e=Ml,V(gi,e),V(sn,t),Ml=e|t.baseLanes}function ur(){V(gi,Ml),V(sn,sn.current)}function fr(){Ml=gi.current,L(sn),L(gi)}var kt=S(null),Xt=null;function Vl(e){var t=e.alternate;V(Ke,Ke.current&1),V(kt,e),Xt===null&&(t===null||sn.current!==null||t.memoizedState!==null)&&(Xt=e)}function dr(e){V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)}function Rf(e){e.tag===22?(V(Ke,Ke.current),V(kt,e),Xt===null&&(Xt=e)):Zl()}function Zl(){V(Ke,Ke.current),V(kt,kt.current)}function Ot(e){L(kt),Xt===e&&(Xt=null),L(Ke)}var Ke=S(0);function bi(e){for(var t=e;t!==null;){if(t.tag===13){var l=t.memoizedState;if(l!==null&&(l=l.dehydrated,l===null||vo(l)||yo(l)))return t}else if(t.tag===19&&(t.memoizedProps.revealOrder==="forwards"||t.memoizedProps.revealOrder==="backwards"||t.memoizedProps.revealOrder==="unstable_legacy-backwards"||t.memoizedProps.revealOrder==="together")){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Sl=0,de=null,Oe=null,Ie=null,vi=!1,cn=!1,_a=!1,yi=0,ts=0,rn=null,Gx=0;function Xe(){throw Error(u(321))}function mr(e,t){if(t===null)return!1;for(var l=0;l<t.length&&l<e.length;l++)if(!Ct(e[l],t[l]))return!1;return!0}function hr(e,t,l,a,s,r){return Sl=r,de=t,t.memoizedState=null,t.updateQueue=null,t.lanes=0,k.H=e===null||e.memoizedState===null?bd:Ar,_a=!1,r=l(a,s),_a=!1,cn&&(r=Hf(t,l,a,s)),Uf(e),r}function Uf(e){k.H=ns;var t=Oe!==null&&Oe.next!==null;if(Sl=0,Ie=Oe=de=null,vi=!1,ts=0,rn=null,t)throw Error(u(300));e===null||Pe||(e=e.dependencies,e!==null&&ui(e)&&(Pe=!0))}function Hf(e,t,l,a){de=e;var s=0;do{if(cn&&(rn=null),ts=0,cn=!1,25<=s)throw Error(u(301));if(s+=1,Ie=Oe=null,e.updateQueue!=null){var r=e.updateQueue;r.lastEffect=null,r.events=null,r.stores=null,r.memoCache!=null&&(r.memoCache.index=0)}k.H=vd,r=t(l,a)}while(cn);return r}function $x(){var e=k.H,t=e.useState()[0];return t=typeof t.then=="function"?ls(t):t,e=e.useState()[0],(Oe!==null?Oe.memoizedState:null)!==e&&(de.flags|=1024),t}function pr(){var e=yi!==0;return yi=0,e}function xr(e,t,l){t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l}function gr(e){if(vi){for(e=e.memoizedState;e!==null;){var t=e.queue;t!==null&&(t.pending=null),e=e.next}vi=!1}Sl=0,Ie=Oe=de=null,cn=!1,ts=yi=0,rn=null}function gt(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e,Ie}function Je(){if(Oe===null){var e=de.alternate;e=e!==null?e.memoizedState:null}else e=Oe.next;var t=Ie===null?de.memoizedState:Ie.next;if(t!==null)Ie=t,Oe=e;else{if(e===null)throw de.alternate===null?Error(u(467)):Error(u(310));Oe=e,e={memoizedState:Oe.memoizedState,baseState:Oe.baseState,baseQueue:Oe.baseQueue,queue:Oe.queue,next:null},Ie===null?de.memoizedState=Ie=e:Ie=Ie.next=e}return Ie}function ji(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function ls(e){var t=ts;return ts+=1,rn===null&&(rn=[]),e=_f(rn,e,t),t=de,(Ie===null?t.memoizedState:Ie.next)===null&&(t=t.alternate,k.H=t===null||t.memoizedState===null?bd:Ar),e}function Ni(e){if(e!==null&&typeof e=="object"){if(typeof e.then=="function")return ls(e);if(e.$$typeof===Z)return rt(e)}throw Error(u(438,String(e)))}function br(e){var t=null,l=de.updateQueue;if(l!==null&&(t=l.memoCache),t==null){var a=de.alternate;a!==null&&(a=a.updateQueue,a!==null&&(a=a.memoCache,a!=null&&(t={data:a.data.map(function(s){return s.slice()}),index:0})))}if(t==null&&(t={data:[],index:0}),l===null&&(l=ji(),de.updateQueue=l),l.memoCache=t,l=t.data[t.index],l===void 0)for(l=t.data[t.index]=Array(e),a=0;a<e;a++)l[a]=Q;return t.index++,l}function zl(e,t){return typeof t=="function"?t(e):t}function Si(e){var t=Je();return vr(t,Oe,e)}function vr(e,t,l){var a=e.queue;if(a===null)throw Error(u(311));a.lastRenderedReducer=l;var s=e.baseQueue,r=a.pending;if(r!==null){if(s!==null){var m=s.next;s.next=r.next,r.next=m}t.baseQueue=s=r,a.pending=null}if(r=e.baseState,s===null)e.memoizedState=r;else{t=s.next;var b=m=null,j=null,C=t,R=!1;do{var B=C.lane&-536870913;if(B!==C.lane?(ge&B)===B:(Sl&B)===B){var M=C.revertLane;if(M===0)j!==null&&(j=j.next={lane:0,revertLane:0,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null}),B===tn&&(R=!0);else if((Sl&M)===M){C=C.next,M===tn&&(R=!0);continue}else B={lane:0,revertLane:C.revertLane,gesture:null,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=B,m=r):j=j.next=B,de.lanes|=M,Wl|=M;B=C.action,_a&&l(r,B),r=C.hasEagerState?C.eagerState:l(r,B)}else M={lane:B,revertLane:C.revertLane,gesture:C.gesture,action:C.action,hasEagerState:C.hasEagerState,eagerState:C.eagerState,next:null},j===null?(b=j=M,m=r):j=j.next=M,de.lanes|=B,Wl|=B;C=C.next}while(C!==null&&C!==t);if(j===null?m=r:j.next=b,!Ct(r,e.memoizedState)&&(Pe=!0,R&&(l=ln,l!==null)))throw l;e.memoizedState=r,e.baseState=m,e.baseQueue=j,a.lastRenderedState=r}return s===null&&(a.lanes=0),[e.memoizedState,a.dispatch]}function yr(e){var t=Je(),l=t.queue;if(l===null)throw Error(u(311));l.lastRenderedReducer=e;var a=l.dispatch,s=l.pending,r=t.memoizedState;if(s!==null){l.pending=null;var m=s=s.next;do r=e(r,m.action),m=m.next;while(m!==s);Ct(r,t.memoizedState)||(Pe=!0),t.memoizedState=r,t.baseQueue===null&&(t.baseState=r),l.lastRenderedState=r}return[r,a]}function Lf(e,t,l){var a=de,s=Je(),r=ve;if(r){if(l===void 0)throw Error(u(407));l=l()}else l=t();var m=!Ct((Oe||s).memoizedState,l);if(m&&(s.memoizedState=l,Pe=!0),s=s.queue,Sr(Yf.bind(null,a,s,e),[e]),s.getSnapshot!==t||m||Ie!==null&&Ie.memoizedState.tag&1){if(a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,s,l,t),null),Ue===null)throw Error(u(349));r||(Sl&127)!==0||Bf(a,t,l)}return l}function Bf(e,t,l){e.flags|=16384,e={getSnapshot:t,value:l},t=de.updateQueue,t===null?(t=ji(),de.updateQueue=t,t.stores=[e]):(l=t.stores,l===null?t.stores=[e]:l.push(e))}function qf(e,t,l,a){t.value=l,t.getSnapshot=a,Gf(t)&&$f(e)}function Yf(e,t,l){return l(function(){Gf(t)&&$f(e)})}function Gf(e){var t=e.getSnapshot;e=e.value;try{var l=t();return!Ct(e,l)}catch{return!0}}function $f(e){var t=ba(e,2);t!==null&&_t(t,e,2)}function jr(e){var t=gt();if(typeof e=="function"){var l=e;if(e=l(),_a){nl(!0);try{l()}finally{nl(!1)}}}return t.memoizedState=t.baseState=e,t.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:e},t}function Qf(e,t,l,a){return e.baseState=l,vr(e,Oe,typeof a=="function"?a:zl)}function Qx(e,t,l,a,s){if(wi(e))throw Error(u(485));if(e=t.action,e!==null){var r={payload:s,action:e,next:null,isTransition:!0,status:"pending",value:null,reason:null,listeners:[],then:function(m){r.listeners.push(m)}};k.T!==null?l(!0):r.isTransition=!1,a(r),l=t.pending,l===null?(r.next=t.pending=r,Xf(t,r)):(r.next=l.next,t.pending=l.next=r)}}function Xf(e,t){var l=t.action,a=t.payload,s=e.state;if(t.isTransition){var r=k.T,m={};k.T=m;try{var b=l(s,a),j=k.S;j!==null&&j(m,b),Vf(e,t,b)}catch(C){Nr(e,t,C)}finally{r!==null&&m.types!==null&&(r.types=m.types),k.T=r}}else try{r=l(s,a),Vf(e,t,r)}catch(C){Nr(e,t,C)}}function Vf(e,t,l){l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(function(a){Zf(e,t,a)},function(a){return Nr(e,t,a)}):Zf(e,t,l)}function Zf(e,t,l){t.status="fulfilled",t.value=l,Kf(t),e.state=l,t=e.pending,t!==null&&(l=t.next,l===t?e.pending=null:(l=l.next,t.next=l,Xf(e,l)))}function Nr(e,t,l){var a=e.pending;if(e.pending=null,a!==null){a=a.next;do t.status="rejected",t.reason=l,Kf(t),t=t.next;while(t!==a)}e.action=null}function Kf(e){e=e.listeners;for(var t=0;t<e.length;t++)(0,e[t])()}function Jf(e,t){return t}function Wf(e,t){if(ve){var l=Ue.formState;if(l!==null){e:{var a=de;if(ve){if(Le){t:{for(var s=Le,r=Qt;s.nodeType!==8;){if(!r){s=null;break t}if(s=Vt(s.nextSibling),s===null){s=null;break t}}r=s.data,s=r==="F!"||r==="F"?s:null}if(s){Le=Vt(s.nextSibling),a=s.data==="F!";break e}}Yl(a)}a=!1}a&&(t=l[0])}}return l=gt(),l.memoizedState=l.baseState=t,a={pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jf,lastRenderedState:t},l.queue=a,l=pd.bind(null,de,a),a.dispatch=l,a=jr(!1),r=Tr.bind(null,de,!1,a.queue),a=gt(),s={state:t,dispatch:null,action:e,pending:null},a.queue=s,l=Qx.bind(null,de,s,r,l),s.dispatch=l,a.memoizedState=e,[t,l,!1]}function Ff(e){var t=Je();return If(t,Oe,e)}function If(e,t,l){if(t=vr(e,t,Jf)[0],e=Si(zl)[0],typeof t=="object"&&t!==null&&typeof t.then=="function")try{var a=ls(t)}catch(m){throw m===an?mi:m}else a=t;t=Je();var s=t.queue,r=s.dispatch;return l!==t.memoizedState&&(de.flags|=2048,on(9,{destroy:void 0},Xx.bind(null,s,l),null)),[a,r,e]}function Xx(e,t){e.action=t}function Pf(e){var t=Je(),l=Oe;if(l!==null)return If(t,l,e);Je(),t=t.memoizedState,l=Je();var a=l.queue.dispatch;return l.memoizedState=e,[t,a,!1]}function on(e,t,l,a){return e={tag:e,create:l,deps:a,inst:t,next:null},t=de.updateQueue,t===null&&(t=ji(),de.updateQueue=t),l=t.lastEffect,l===null?t.lastEffect=e.next=e:(a=l.next,l.next=e,e.next=a,t.lastEffect=e),e}function ed(){return Je().memoizedState}function zi(e,t,l,a){var s=gt();de.flags|=e,s.memoizedState=on(1|t,{destroy:void 0},l,a===void 0?null:a)}function Ei(e,t,l,a){var s=Je();a=a===void 0?null:a;var r=s.memoizedState.inst;Oe!==null&&a!==null&&mr(a,Oe.memoizedState.deps)?s.memoizedState=on(t,r,l,a):(de.flags|=e,s.memoizedState=on(1|t,r,l,a))}function td(e,t){zi(8390656,8,e,t)}function Sr(e,t){Ei(2048,8,e,t)}function Vx(e){de.flags|=4;var t=de.updateQueue;if(t===null)t=ji(),de.updateQueue=t,t.events=[e];else{var l=t.events;l===null?t.events=[e]:l.push(e)}}function ld(e){var t=Je().memoizedState;return Vx({ref:t,nextImpl:e}),function(){if((we&2)!==0)throw Error(u(440));return t.impl.apply(void 0,arguments)}}function ad(e,t){return Ei(4,2,e,t)}function nd(e,t){return Ei(4,4,e,t)}function sd(e,t){if(typeof t=="function"){e=e();var l=t(e);return function(){typeof l=="function"?l():t(null)}}if(t!=null)return e=e(),t.current=e,function(){t.current=null}}function id(e,t,l){l=l!=null?l.concat([e]):null,Ei(4,4,sd.bind(null,t,e),l)}function zr(){}function cd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;return t!==null&&mr(t,a[1])?a[0]:(l.memoizedState=[e,t],e)}function rd(e,t){var l=Je();t=t===void 0?null:t;var a=l.memoizedState;if(t!==null&&mr(t,a[1]))return a[0];if(a=e(),_a){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a}function Er(e,t,l){return l===void 0||(Sl&1073741824)!==0&&(ge&261930)===0?e.memoizedState=t:(e.memoizedState=l,e=om(),de.lanes|=e,Wl|=e,l)}function od(e,t,l,a){return Ct(l,t)?l:sn.current!==null?(e=Er(e,l,a),Ct(e,t)||(Pe=!0),e):(Sl&42)===0||(Sl&1073741824)!==0&&(ge&261930)===0?(Pe=!0,e.memoizedState=l):(e=om(),de.lanes|=e,Wl|=e,t)}function ud(e,t,l,a,s){var r=Y.p;Y.p=r!==0&&8>r?r:8;var m=k.T,b={};k.T=b,Tr(e,!1,t,l);try{var j=s(),C=k.S;if(C!==null&&C(b,j),j!==null&&typeof j=="object"&&typeof j.then=="function"){var R=Yx(j,a);as(e,t,R,Ut(e))}else as(e,t,a,Ut(e))}catch(B){as(e,t,{then:function(){},status:"rejected",reason:B},Ut())}finally{Y.p=r,m!==null&&b.types!==null&&(m.types=b.types),k.T=m}}function Zx(){}function wr(e,t,l,a){if(e.tag!==5)throw Error(u(476));var s=fd(e).queue;ud(e,s,t,te,l===null?Zx:function(){return dd(e),l(a)})}function fd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:te,baseState:te,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:te},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zl,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function dd(e){var t=fd(e);t.next===null&&(t=e.alternate.memoizedState),as(e,t.next.queue,{},Ut())}function _r(){return rt(ys)}function md(){return Je().memoizedState}function hd(){return Je().memoizedState}function Kx(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Ut();e=Ql(l);var a=Xl(t,e,l);a!==null&&(_t(a,t,l),In(a,t,l)),t={cache:lr()},e.payload=t;return}t=t.return}}function Jx(e,t,l){var a=Ut();l={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},wi(e)?xd(t,l):(l=Xc(e,t,l,a),l!==null&&(_t(l,e,a),gd(l,t,a)))}function pd(e,t,l){var a=Ut();as(e,t,l,a)}function as(e,t,l,a){var s={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(wi(e))xd(t,s);else{var r=e.alternate;if(e.lanes===0&&(r===null||r.lanes===0)&&(r=t.lastRenderedReducer,r!==null))try{var m=t.lastRenderedState,b=r(m,l);if(s.hasEagerState=!0,s.eagerState=b,Ct(b,m))return ii(e,t,s,0),Ue===null&&si(),!1}catch{}finally{}if(l=Xc(e,t,s,a),l!==null)return _t(l,e,a),gd(l,t,a),!0}return!1}function Tr(e,t,l,a){if(a={lane:2,revertLane:io(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},wi(e)){if(t)throw Error(u(479))}else t=Xc(e,l,a,2),t!==null&&_t(t,e,2)}function wi(e){var t=e.alternate;return e===de||t!==null&&t===de}function xd(e,t){cn=vi=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function gd(e,t,l){if((l&4194048)!==0){var a=t.lanes;a&=e.pendingLanes,l|=a,t.lanes=l,ju(e,l)}}var ns={readContext:rt,use:Ni,useCallback:Xe,useContext:Xe,useEffect:Xe,useImperativeHandle:Xe,useLayoutEffect:Xe,useInsertionEffect:Xe,useMemo:Xe,useReducer:Xe,useRef:Xe,useState:Xe,useDebugValue:Xe,useDeferredValue:Xe,useTransition:Xe,useSyncExternalStore:Xe,useId:Xe,useHostTransitionStatus:Xe,useFormState:Xe,useActionState:Xe,useOptimistic:Xe,useMemoCache:Xe,useCacheRefresh:Xe};ns.useEffectEvent=Xe;var bd={readContext:rt,use:Ni,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:rt,useEffect:td,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,zi(4194308,4,sd.bind(null,t,e),l)},useLayoutEffect:function(e,t){return zi(4194308,4,e,t)},useInsertionEffect:function(e,t){zi(4,2,e,t)},useMemo:function(e,t){var l=gt();t=t===void 0?null:t;var a=e();if(_a){nl(!0);try{e()}finally{nl(!1)}}return l.memoizedState=[a,t],a},useReducer:function(e,t,l){var a=gt();if(l!==void 0){var s=l(t);if(_a){nl(!0);try{l(t)}finally{nl(!1)}}}else s=t;return a.memoizedState=a.baseState=s,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:s},a.queue=e,e=e.dispatch=Jx.bind(null,de,e),[a.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:function(e){e=jr(e);var t=e.queue,l=pd.bind(null,de,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:zr,useDeferredValue:function(e,t){var l=gt();return Er(l,e,t)},useTransition:function(){var e=jr(!1);return e=ud.bind(null,de,e.queue,!0,!1),gt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var a=de,s=gt();if(ve){if(l===void 0)throw Error(u(407));l=l()}else{if(l=t(),Ue===null)throw Error(u(349));(ge&127)!==0||Bf(a,t,l)}s.memoizedState=l;var r={value:l,getSnapshot:t};return s.queue=r,td(Yf.bind(null,a,r,e),[e]),a.flags|=2048,on(9,{destroy:void 0},qf.bind(null,a,r,l,t),null),l},useId:function(){var e=gt(),t=Ue.identifierPrefix;if(ve){var l=cl,a=il;l=(a&~(1<<32-xt(a)-1)).toString(32)+l,t="_"+t+"R_"+l,l=yi++,0<l&&(t+="H"+l.toString(32)),t+="_"}else l=Gx++,t="_"+t+"r_"+l.toString(32)+"_";return e.memoizedState=t},useHostTransitionStatus:_r,useFormState:Wf,useActionState:Wf,useOptimistic:function(e){var t=gt();t.memoizedState=t.baseState=e;var l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return t.queue=l,t=Tr.bind(null,de,!0,l),l.dispatch=t,[e,t]},useMemoCache:br,useCacheRefresh:function(){return gt().memoizedState=Kx.bind(null,de)},useEffectEvent:function(e){var t=gt(),l={impl:e};return t.memoizedState=l,function(){if((we&2)!==0)throw Error(u(440));return l.impl.apply(void 0,arguments)}}},Ar={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:Si,useRef:ed,useState:function(){return Si(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=Si(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:_r,useFormState:Ff,useActionState:Ff,useOptimistic:function(e,t){var l=Je();return Qf(l,Oe,e,t)},useMemoCache:br,useCacheRefresh:hd};Ar.useEffectEvent=ld;var vd={readContext:rt,use:Ni,useCallback:cd,useContext:rt,useEffect:Sr,useImperativeHandle:id,useInsertionEffect:ad,useLayoutEffect:nd,useMemo:rd,useReducer:yr,useRef:ed,useState:function(){return yr(zl)},useDebugValue:zr,useDeferredValue:function(e,t){var l=Je();return Oe===null?Er(l,e,t):od(l,Oe.memoizedState,e,t)},useTransition:function(){var e=yr(zl)[0],t=Je().memoizedState;return[typeof e=="boolean"?e:ls(e),t]},useSyncExternalStore:Lf,useId:md,useHostTransitionStatus:_r,useFormState:Pf,useActionState:Pf,useOptimistic:function(e,t){var l=Je();return Oe!==null?Qf(l,Oe,e,t):(l.baseState=e,[e,l.queue.dispatch])},useMemoCache:br,useCacheRefresh:hd};vd.useEffectEvent=ld;function Cr(e,t,l,a){t=e.memoizedState,l=l(a,t),l=l==null?t:y({},t,l),e.memoizedState=l,e.lanes===0&&(e.updateQueue.baseState=l)}var Mr={enqueueSetState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(_t(t,e,a),In(t,e,a))},enqueueReplaceState:function(e,t,l){e=e._reactInternals;var a=Ut(),s=Ql(a);s.tag=1,s.payload=t,l!=null&&(s.callback=l),t=Xl(e,s,a),t!==null&&(_t(t,e,a),In(t,e,a))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var l=Ut(),a=Ql(l);a.tag=2,t!=null&&(a.callback=t),t=Xl(e,a,l),t!==null&&(_t(t,e,l),In(t,e,l))}};function yd(e,t,l,a,s,r,m){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(a,r,m):t.prototype&&t.prototype.isPureReactComponent?!Qn(l,a)||!Qn(s,r):!0}function jd(e,t,l,a){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(l,a),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(l,a),t.state!==e&&Mr.enqueueReplaceState(t,t.state,null)}function Ta(e,t){var l=t;if("ref"in t){l={};for(var a in t)a!=="ref"&&(l[a]=t[a])}if(e=e.defaultProps){l===t&&(l=y({},l));for(var s in e)l[s]===void 0&&(l[s]=e[s])}return l}function Nd(e){ni(e)}function Sd(e){console.error(e)}function zd(e){ni(e)}function _i(e,t){try{var l=e.onUncaughtError;l(t.value,{componentStack:t.stack})}catch(a){setTimeout(function(){throw a})}}function Ed(e,t,l){try{var a=e.onCaughtError;a(l.value,{componentStack:l.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(s){setTimeout(function(){throw s})}}function kr(e,t,l){return l=Ql(l),l.tag=3,l.payload={element:null},l.callback=function(){_i(e,t)},l}function wd(e){return e=Ql(e),e.tag=3,e}function _d(e,t,l,a){var s=l.type.getDerivedStateFromError;if(typeof s=="function"){var r=a.value;e.payload=function(){return s(r)},e.callback=function(){Ed(t,l,a)}}var m=l.stateNode;m!==null&&typeof m.componentDidCatch=="function"&&(e.callback=function(){Ed(t,l,a),typeof s!="function"&&(Fl===null?Fl=new Set([this]):Fl.add(this));var b=a.stack;this.componentDidCatch(a.value,{componentStack:b!==null?b:""})})}function Wx(e,t,l,a,s){if(l.flags|=32768,a!==null&&typeof a=="object"&&typeof a.then=="function"){if(t=l.alternate,t!==null&&en(t,l,s,!0),l=kt.current,l!==null){switch(l.tag){case 31:case 13:return Xt===null?Bi():l.alternate===null&&Ve===0&&(Ve=3),l.flags&=-257,l.flags|=65536,l.lanes=s,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?l.updateQueue=new Set([a]):t.add(a),ao(e,a,s)),!1;case 22:return l.flags|=65536,a===hi?l.flags|=16384:(t=l.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([a])},l.updateQueue=t):(l=t.retryQueue,l===null?t.retryQueue=new Set([a]):l.add(a)),ao(e,a,s)),!1}throw Error(u(435,l.tag))}return ao(e,a,s),Bi(),!1}if(ve)return t=kt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=s,a!==Fc&&(e=Error(u(422),{cause:a}),Zn(Yt(e,l)))):(a!==Fc&&(t=Error(u(423),{cause:a}),Zn(Yt(t,l))),e=e.current.alternate,e.flags|=65536,s&=-s,e.lanes|=s,a=Yt(a,l),s=kr(e.stateNode,a,s),rr(e,s),Ve!==4&&(Ve=2)),!1;var r=Error(u(520),{cause:a});if(r=Yt(r,l),ds===null?ds=[r]:ds.push(r),Ve!==4&&(Ve=2),t===null)return!0;a=Yt(a,l),l=t;do{switch(l.tag){case 3:return l.flags|=65536,e=s&-s,l.lanes|=e,e=kr(l.stateNode,a,e),rr(l,e),!1;case 1:if(t=l.type,r=l.stateNode,(l.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||r!==null&&typeof r.componentDidCatch=="function"&&(Fl===null||!Fl.has(r))))return l.flags|=65536,s&=-s,l.lanes|=s,s=wd(s),_d(s,e,l,a),rr(l,s),!1}l=l.return}while(l!==null);return!1}var Or=Error(u(461)),Pe=!1;function ot(e,t,l,a){t.child=e===null?Mf(t,null,l,a):wa(t,e.child,l,a)}function Td(e,t,l,a,s){l=l.render;var r=t.ref;if("ref"in a){var m={};for(var b in a)b!=="ref"&&(m[b]=a[b])}else m=a;return Na(t),a=hr(e,t,l,m,r,s),b=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&b&&Jc(t),t.flags|=1,ot(e,t,a,s),t.child)}function Ad(e,t,l,a,s){if(e===null){var r=l.type;return typeof r=="function"&&!Vc(r)&&r.defaultProps===void 0&&l.compare===null?(t.tag=15,t.type=r,Cd(e,t,r,a,s)):(e=ri(l.type,null,a,t,t.mode,s),e.ref=t.ref,e.return=t,t.child=e)}if(r=e.child,!Yr(e,s)){var m=r.memoizedProps;if(l=l.compare,l=l!==null?l:Qn,l(m,a)&&e.ref===t.ref)return El(e,t,s)}return t.flags|=1,e=vl(r,a),e.ref=t.ref,e.return=t,t.child=e}function Cd(e,t,l,a,s){if(e!==null){var r=e.memoizedProps;if(Qn(r,a)&&e.ref===t.ref)if(Pe=!1,t.pendingProps=a=r,Yr(e,s))(e.flags&131072)!==0&&(Pe=!0);else return t.lanes=e.lanes,El(e,t,s)}return Dr(e,t,l,a,s)}function Md(e,t,l,a){var s=a.children,r=e!==null?e.memoizedState:null;if(e===null&&t.stateNode===null&&(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),a.mode==="hidden"){if((t.flags&128)!==0){if(r=r!==null?r.baseLanes|l:l,e!==null){for(a=t.child=e.child,s=0;a!==null;)s=s|a.lanes|a.childLanes,a=a.sibling;a=s&~r}else a=0,t.child=null;return kd(e,t,r,l,a)}if((l&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&di(t,r!==null?r.cachePool:null),r!==null?Df(t,r):ur(),Rf(t);else return a=t.lanes=536870912,kd(e,t,r!==null?r.baseLanes|l:l,l,a)}else r!==null?(di(t,r.cachePool),Df(t,r),Zl(),t.memoizedState=null):(e!==null&&di(t,null),ur(),Zl());return ot(e,t,s,l),t.child}function ss(e,t){return e!==null&&e.tag===22||t.stateNode!==null||(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),t.sibling}function kd(e,t,l,a,s){var r=nr();return r=r===null?null:{parent:Fe._currentValue,pool:r},t.memoizedState={baseLanes:l,cachePool:r},e!==null&&di(t,null),ur(),Rf(t),e!==null&&en(e,t,a,!0),t.childLanes=s,null}function Ti(e,t){return t=Ci({mode:t.mode,children:t.children},e.mode),t.ref=e.ref,e.child=t,t.return=e,t}function Od(e,t,l){return wa(t,e.child,null,l),e=Ti(t,t.pendingProps),e.flags|=2,Ot(t),t.memoizedState=null,e}function Fx(e,t,l){var a=t.pendingProps,s=(t.flags&128)!==0;if(t.flags&=-129,e===null){if(ve){if(a.mode==="hidden")return e=Ti(t,a),t.lanes=536870912,ss(null,e);if(dr(t),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data==="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return t.lanes=536870912,null}return Ti(t,a)}var r=e.memoizedState;if(r!==null){var m=r.dehydrated;if(dr(t),s)if(t.flags&256)t.flags&=-257,t=Od(e,t,l);else if(t.memoizedState!==null)t.child=e.child,t.flags|=128,t=null;else throw Error(u(558));else if(Pe||en(e,t,l,!1),s=(l&e.childLanes)!==0,Pe||s){if(a=Ue,a!==null&&(m=Nu(a,l),m!==0&&m!==r.retryLane))throw r.retryLane=m,ba(e,m),_t(a,e,m),Or;Bi(),t=Od(e,t,l)}else e=r.treeContext,Le=Vt(m.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Ti(t,a),t.flags|=4096;return t}return e=vl(e.child,{mode:a.mode,children:a.children}),e.ref=t.ref,t.child=e,e.return=t,e}function Ai(e,t){var l=t.ref;if(l===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof l!="function"&&typeof l!="object")throw Error(u(284));(e===null||e.ref!==l)&&(t.flags|=4194816)}}function Dr(e,t,l,a,s){return Na(t),l=hr(e,t,l,a,void 0,s),a=pr(),e!==null&&!Pe?(xr(e,t,s),El(e,t,s)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,s),t.child)}function Dd(e,t,l,a,s,r){return Na(t),t.updateQueue=null,l=Hf(t,a,l,s),Uf(e),a=pr(),e!==null&&!Pe?(xr(e,t,r),El(e,t,r)):(ve&&a&&Jc(t),t.flags|=1,ot(e,t,l,r),t.child)}function Rd(e,t,l,a,s){if(Na(t),t.stateNode===null){var r=Wa,m=l.contextType;typeof m=="object"&&m!==null&&(r=rt(m)),r=new l(a,r),t.memoizedState=r.state!==null&&r.state!==void 0?r.state:null,r.updater=Mr,t.stateNode=r,r._reactInternals=t,r=t.stateNode,r.props=a,r.state=t.memoizedState,r.refs={},ir(t),m=l.contextType,r.context=typeof m=="object"&&m!==null?rt(m):Wa,r.state=t.memoizedState,m=l.getDerivedStateFromProps,typeof m=="function"&&(Cr(t,l,m,a),r.state=t.memoizedState),typeof l.getDerivedStateFromProps=="function"||typeof r.getSnapshotBeforeUpdate=="function"||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(m=r.state,typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount(),m!==r.state&&Mr.enqueueReplaceState(r,r.state,null),es(t,a,r,s),Pn(),r.state=t.memoizedState),typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!0}else if(e===null){r=t.stateNode;var b=t.memoizedProps,j=Ta(l,b);r.props=j;var C=r.context,R=l.contextType;m=Wa,typeof R=="object"&&R!==null&&(m=rt(R));var B=l.getDerivedStateFromProps;R=typeof B=="function"||typeof r.getSnapshotBeforeUpdate=="function",b=t.pendingProps!==b,R||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(b||C!==m)&&jd(t,r,a,m),$l=!1;var M=t.memoizedState;r.state=M,es(t,a,r,s),Pn(),C=t.memoizedState,b||M!==C||$l?(typeof B=="function"&&(Cr(t,l,B,a),C=t.memoizedState),(j=$l||yd(t,l,j,a,M,C,m))?(R||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount()),typeof r.componentDidMount=="function"&&(t.flags|=4194308)):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=a,t.memoizedState=C),r.props=a,r.state=C,r.context=m,a=j):(typeof r.componentDidMount=="function"&&(t.flags|=4194308),a=!1)}else{r=t.stateNode,cr(e,t),m=t.memoizedProps,R=Ta(l,m),r.props=R,B=t.pendingProps,M=r.context,C=l.contextType,j=Wa,typeof C=="object"&&C!==null&&(j=rt(C)),b=l.getDerivedStateFromProps,(C=typeof b=="function"||typeof r.getSnapshotBeforeUpdate=="function")||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(m!==B||M!==j)&&jd(t,r,a,j),$l=!1,M=t.memoizedState,r.state=M,es(t,a,r,s),Pn();var O=t.memoizedState;m!==B||M!==O||$l||e!==null&&e.dependencies!==null&&ui(e.dependencies)?(typeof b=="function"&&(Cr(t,l,b,a),O=t.memoizedState),(R=$l||yd(t,l,R,a,M,O,j)||e!==null&&e.dependencies!==null&&ui(e.dependencies))?(C||typeof r.UNSAFE_componentWillUpdate!="function"&&typeof r.componentWillUpdate!="function"||(typeof r.componentWillUpdate=="function"&&r.componentWillUpdate(a,O,j),typeof r.UNSAFE_componentWillUpdate=="function"&&r.UNSAFE_componentWillUpdate(a,O,j)),typeof r.componentDidUpdate=="function"&&(t.flags|=4),typeof r.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),t.memoizedProps=a,t.memoizedState=O),r.props=a,r.state=O,r.context=j,a=R):(typeof r.componentDidUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||m===e.memoizedProps&&M===e.memoizedState||(t.flags|=1024),a=!1)}return r=a,Ai(e,t),a=(t.flags&128)!==0,r||a?(r=t.stateNode,l=a&&typeof l.getDerivedStateFromError!="function"?null:r.render(),t.flags|=1,e!==null&&a?(t.child=wa(t,e.child,null,s),t.child=wa(t,null,l,s)):ot(e,t,l,s),t.memoizedState=r.state,e=t.child):e=El(e,t,s),e}function Ud(e,t,l,a){return ya(),t.flags|=256,ot(e,t,l,a),t.child}var Rr={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Ur(e){return{baseLanes:e,cachePool:Ef()}}function Hr(e,t,l){return e=e!==null?e.childLanes&~l:0,t&&(e|=Rt),e}function Hd(e,t,l){var a=t.pendingProps,s=!1,r=(t.flags&128)!==0,m;if((m=r)||(m=e!==null&&e.memoizedState===null?!1:(Ke.current&2)!==0),m&&(s=!0,t.flags&=-129),m=(t.flags&32)!==0,t.flags&=-33,e===null){if(ve){if(s?Vl(t):Zl(),(e=Le)?(e=Xm(e,Qt),e=e!==null&&e.data!=="&"?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:Bl!==null?{id:il,overflow:cl}:null,retryLane:536870912,hydrationErrors:null},l=xf(e),l.return=t,t.child=l,ct=t,Le=null)):e=null,e===null)throw Yl(t);return yo(e)?t.lanes=32:t.lanes=536870912,null}var b=a.children;return a=a.fallback,s?(Zl(),s=t.mode,b=Ci({mode:"hidden",children:b},s),a=va(a,s,l,null),b.return=t,a.return=t,b.sibling=a,t.child=b,a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(null,a)):(Vl(t),Lr(t,b))}var j=e.memoizedState;if(j!==null&&(b=j.dehydrated,b!==null)){if(r)t.flags&256?(Vl(t),t.flags&=-257,t=Br(e,t,l)):t.memoizedState!==null?(Zl(),t.child=e.child,t.flags|=128,t=null):(Zl(),b=a.fallback,s=t.mode,a=Ci({mode:"visible",children:a.children},s),b=va(b,s,l,null),b.flags|=2,a.return=t,b.return=t,a.sibling=b,t.child=a,wa(t,e.child,null,l),a=t.child,a.memoizedState=Ur(l),a.childLanes=Hr(e,m,l),t.memoizedState=Rr,t=ss(null,a));else if(Vl(t),yo(b)){if(m=b.nextSibling&&b.nextSibling.dataset,m)var C=m.dgst;m=C,a=Error(u(419)),a.stack="",a.digest=m,Zn({value:a,source:null,stack:null}),t=Br(e,t,l)}else if(Pe||en(e,t,l,!1),m=(l&e.childLanes)!==0,Pe||m){if(m=Ue,m!==null&&(a=Nu(m,l),a!==0&&a!==j.retryLane))throw j.retryLane=a,ba(e,a),_t(m,e,a),Or;vo(b)||Bi(),t=Br(e,t,l)}else vo(b)?(t.flags|=192,t.child=e.child,t=null):(e=j.treeContext,Le=Vt(b.nextSibling),ct=t,ve=!0,ql=null,Qt=!1,e!==null&&vf(t,e),t=Lr(t,a.children),t.flags|=4096);return t}return s?(Zl(),b=a.fallback,s=t.mode,j=e.child,C=j.sibling,a=vl(j,{mode:"hidden",children:a.children}),a.subtreeFlags=j.subtreeFlags&65011712,C!==null?b=vl(C,b):(b=va(b,s,l,null),b.flags|=2),b.return=t,a.return=t,a.sibling=b,t.child=a,ss(null,a),a=t.child,b=e.child.memoizedState,b===null?b=Ur(l):(s=b.cachePool,s!==null?(j=Fe._currentValue,s=s.parent!==j?{parent:j,pool:j}:s):s=Ef(),b={baseLanes:b.baseLanes|l,cachePool:s}),a.memoizedState=b,a.childLanes=Hr(e,m,l),t.memoizedState=Rr,ss(e.child,a)):(Vl(t),l=e.child,e=l.sibling,l=vl(l,{mode:"visible",children:a.children}),l.return=t,l.sibling=null,e!==null&&(m=t.deletions,m===null?(t.deletions=[e],t.flags|=16):m.push(e)),t.child=l,t.memoizedState=null,l)}function Lr(e,t){return t=Ci({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Ci(e,t){return e=Mt(22,e,null,t),e.lanes=0,e}function Br(e,t,l){return wa(t,e.child,null,l),e=Lr(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Ld(e,t,l){e.lanes|=t;var a=e.alternate;a!==null&&(a.lanes|=t),er(e.return,t,l)}function qr(e,t,l,a,s,r){var m=e.memoizedState;m===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:a,tail:l,tailMode:s,treeForkCount:r}:(m.isBackwards=t,m.rendering=null,m.renderingStartTime=0,m.last=a,m.tail=l,m.tailMode=s,m.treeForkCount=r)}function Bd(e,t,l){var a=t.pendingProps,s=a.revealOrder,r=a.tail;a=a.children;var m=Ke.current,b=(m&2)!==0;if(b?(m=m&1|2,t.flags|=128):m&=1,V(Ke,m),ot(e,t,a,l),a=ve?Vn:0,!b&&e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Ld(e,l,t);else if(e.tag===19)Ld(e,l,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}switch(s){case"forwards":for(l=t.child,s=null;l!==null;)e=l.alternate,e!==null&&bi(e)===null&&(s=l),l=l.sibling;l=s,l===null?(s=t.child,t.child=null):(s=l.sibling,l.sibling=null),qr(t,!1,s,l,r,a);break;case"backwards":case"unstable_legacy-backwards":for(l=null,s=t.child,t.child=null;s!==null;){if(e=s.alternate,e!==null&&bi(e)===null){t.child=s;break}e=s.sibling,s.sibling=l,l=s,s=e}qr(t,!0,l,null,r,a);break;case"together":qr(t,!1,null,null,void 0,a);break;default:t.memoizedState=null}return t.child}function El(e,t,l){if(e!==null&&(t.dependencies=e.dependencies),Wl|=t.lanes,(l&t.childLanes)===0)if(e!==null){if(en(e,t,l,!1),(l&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(u(153));if(t.child!==null){for(e=t.child,l=vl(e,e.pendingProps),t.child=l,l.return=t;e.sibling!==null;)e=e.sibling,l=l.sibling=vl(e,e.pendingProps),l.return=t;l.sibling=null}return t.child}function Yr(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&ui(e)))}function Ix(e,t,l){switch(t.tag){case 3:ze(t,t.stateNode.containerInfo),Gl(t,Fe,e.memoizedState.cache),ya();break;case 27:case 5:at(t);break;case 4:ze(t,t.stateNode.containerInfo);break;case 10:Gl(t,t.type,t.memoizedProps.value);break;case 31:if(t.memoizedState!==null)return t.flags|=128,dr(t),null;break;case 13:var a=t.memoizedState;if(a!==null)return a.dehydrated!==null?(Vl(t),t.flags|=128,null):(l&t.child.childLanes)!==0?Hd(e,t,l):(Vl(t),e=El(e,t,l),e!==null?e.sibling:null);Vl(t);break;case 19:var s=(e.flags&128)!==0;if(a=(l&t.childLanes)!==0,a||(en(e,t,l,!1),a=(l&t.childLanes)!==0),s){if(a)return Bd(e,t,l);t.flags|=128}if(s=t.memoizedState,s!==null&&(s.rendering=null,s.tail=null,s.lastEffect=null),V(Ke,Ke.current),a)break;return null;case 22:return t.lanes=0,Md(e,t,l,t.pendingProps);case 24:Gl(t,Fe,e.memoizedState.cache)}return El(e,t,l)}function qd(e,t,l){if(e!==null)if(e.memoizedProps!==t.pendingProps)Pe=!0;else{if(!Yr(e,l)&&(t.flags&128)===0)return Pe=!1,Ix(e,t,l);Pe=(e.flags&131072)!==0}else Pe=!1,ve&&(t.flags&1048576)!==0&&bf(t,Vn,t.index);switch(t.lanes=0,t.tag){case 16:e:{var a=t.pendingProps;if(e=za(t.elementType),t.type=e,typeof e=="function")Vc(e)?(a=Ta(e,a),t.tag=1,t=Rd(null,t,e,a,l)):(t.tag=0,t=Dr(null,t,e,a,l));else{if(e!=null){var s=e.$$typeof;if(s===P){t.tag=11,t=Td(null,t,e,a,l);break e}else if(s===G){t.tag=14,t=Ad(null,t,e,a,l);break e}}throw t=Qe(e)||e,Error(u(306,t,""))}}return t;case 0:return Dr(e,t,t.type,t.pendingProps,l);case 1:return a=t.type,s=Ta(a,t.pendingProps),Rd(e,t,a,s,l);case 3:e:{if(ze(t,t.stateNode.containerInfo),e===null)throw Error(u(387));a=t.pendingProps;var r=t.memoizedState;s=r.element,cr(e,t),es(t,a,null,l);var m=t.memoizedState;if(a=m.cache,Gl(t,Fe,a),a!==r.cache&&tr(t,[Fe],l,!0),Pn(),a=m.element,r.isDehydrated)if(r={element:a,isDehydrated:!1,cache:m.cache},t.updateQueue.baseState=r,t.memoizedState=r,t.flags&256){t=Ud(e,t,a,l);break e}else if(a!==s){s=Yt(Error(u(424)),t),Zn(s),t=Ud(e,t,a,l);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Le=Vt(e.firstChild),ct=t,ve=!0,ql=null,Qt=!0,l=Mf(t,null,a,l),t.child=l;l;)l.flags=l.flags&-3|4096,l=l.sibling}else{if(ya(),a===s){t=El(e,t,l);break e}ot(e,t,a,l)}t=t.child}return t;case 26:return Ai(e,t),e===null?(l=Fm(t.type,null,t.pendingProps,null))?t.memoizedState=l:ve||(l=t.type,e=t.pendingProps,a=Vi(fe.current).createElement(l),a[it]=t,a[jt]=e,ut(a,l,e),nt(a),t.stateNode=a):t.memoizedState=Fm(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return at(t),e===null&&ve&&(a=t.stateNode=Km(t.type,t.pendingProps,fe.current),ct=t,Qt=!0,s=Le,ta(t.type)?(jo=s,Le=Vt(a.firstChild)):Le=s),ot(e,t,t.pendingProps.children,l),Ai(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&ve&&((s=a=Le)&&(a=Tg(a,t.type,t.pendingProps,Qt),a!==null?(t.stateNode=a,ct=t,Le=Vt(a.firstChild),Qt=!1,s=!0):s=!1),s||Yl(t)),at(t),s=t.type,r=t.pendingProps,m=e!==null?e.memoizedProps:null,a=r.children,xo(s,r)?a=null:m!==null&&xo(s,m)&&(t.flags|=32),t.memoizedState!==null&&(s=hr(e,t,$x,null,null,l),ys._currentValue=s),Ai(e,t),ot(e,t,a,l),t.child;case 6:return e===null&&ve&&((e=l=Le)&&(l=Ag(l,t.pendingProps,Qt),l!==null?(t.stateNode=l,ct=t,Le=null,e=!0):e=!1),e||Yl(t)),null;case 13:return Hd(e,t,l);case 4:return ze(t,t.stateNode.containerInfo),a=t.pendingProps,e===null?t.child=wa(t,null,a,l):ot(e,t,a,l),t.child;case 11:return Td(e,t,t.type,t.pendingProps,l);case 7:return ot(e,t,t.pendingProps,l),t.child;case 8:return ot(e,t,t.pendingProps.children,l),t.child;case 12:return ot(e,t,t.pendingProps.children,l),t.child;case 10:return a=t.pendingProps,Gl(t,t.type,a.value),ot(e,t,a.children,l),t.child;case 9:return s=t.type._context,a=t.pendingProps.children,Na(t),s=rt(s),a=a(s),t.flags|=1,ot(e,t,a,l),t.child;case 14:return Ad(e,t,t.type,t.pendingProps,l);case 15:return Cd(e,t,t.type,t.pendingProps,l);case 19:return Bd(e,t,l);case 31:return Fx(e,t,l);case 22:return Md(e,t,l,t.pendingProps);case 24:return Na(t),a=rt(Fe),e===null?(s=nr(),s===null&&(s=Ue,r=lr(),s.pooledCache=r,r.refCount++,r!==null&&(s.pooledCacheLanes|=l),s=r),t.memoizedState={parent:a,cache:s},ir(t),Gl(t,Fe,s)):((e.lanes&l)!==0&&(cr(e,t),es(t,null,null,l),Pn()),s=e.memoizedState,r=t.memoizedState,s.parent!==a?(s={parent:a,cache:a},t.memoizedState=s,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=s),Gl(t,Fe,a)):(a=r.cache,Gl(t,Fe,a),a!==s.cache&&tr(t,[Fe],l,!0))),ot(e,t,t.pendingProps.children,l),t.child;case 29:throw t.pendingProps}throw Error(u(156,t.tag))}function wl(e){e.flags|=4}function Gr(e,t,l,a,s){if((t=(e.mode&32)!==0)&&(t=!1),t){if(e.flags|=16777216,(s&335544128)===s)if(e.stateNode.complete)e.flags|=8192;else if(mm())e.flags|=8192;else throw Ea=hi,sr}else e.flags&=-16777217}function Yd(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!lh(t))if(mm())e.flags|=8192;else throw Ea=hi,sr}function Mi(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?Zs():536870912,e.lanes|=t,mn|=t)}function is(e,t){if(!ve)switch(e.tailMode){case"hidden":t=e.tail;for(var l=null;t!==null;)t.alternate!==null&&(l=t),t=t.sibling;l===null?e.tail=null:l.sibling=null;break;case"collapsed":l=e.tail;for(var a=null;l!==null;)l.alternate!==null&&(a=l),l=l.sibling;a===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:a.sibling=null}}function Be(e){var t=e.alternate!==null&&e.alternate.child===e.child,l=0,a=0;if(t)for(var s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags&65011712,a|=s.flags&65011712,s.return=e,s=s.sibling;else for(s=e.child;s!==null;)l|=s.lanes|s.childLanes,a|=s.subtreeFlags,a|=s.flags,s.return=e,s=s.sibling;return e.subtreeFlags|=a,e.childLanes=l,t}function Px(e,t,l){var a=t.pendingProps;switch(Wc(t),t.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Be(t),null;case 1:return Be(t),null;case 3:return l=t.stateNode,a=null,e!==null&&(a=e.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Nl(Fe),Ee(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(e===null||e.child===null)&&(Pa(t)?wl(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,Ic())),Be(t),null;case 26:var s=t.type,r=t.memoizedState;return e===null?(wl(t),r!==null?(Be(t),Yd(t,r)):(Be(t),Gr(t,s,null,a,l))):r?r!==e.memoizedState?(wl(t),Be(t),Yd(t,r)):(Be(t),t.flags&=-16777217):(e=e.memoizedProps,e!==a&&wl(t),Be(t),Gr(t,s,e,a,l)),null;case 27:if(Me(t),l=fe.current,s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}e=J.current,Pa(t)?yf(t):(e=Km(s,a,l),t.stateNode=e,wl(t))}return Be(t),null;case 5:if(Me(t),s=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(!a){if(t.stateNode===null)throw Error(u(166));return Be(t),null}if(r=J.current,Pa(t))yf(t);else{var m=Vi(fe.current);switch(r){case 1:r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case 2:r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;default:switch(s){case"svg":r=m.createElementNS("http://www.w3.org/2000/svg",s);break;case"math":r=m.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;case"script":r=m.createElement("div"),r.innerHTML="<script><\/script>",r=r.removeChild(r.firstChild);break;case"select":r=typeof a.is=="string"?m.createElement("select",{is:a.is}):m.createElement("select"),a.multiple?r.multiple=!0:a.size&&(r.size=a.size);break;default:r=typeof a.is=="string"?m.createElement(s,{is:a.is}):m.createElement(s)}}r[it]=t,r[jt]=a;e:for(m=t.child;m!==null;){if(m.tag===5||m.tag===6)r.appendChild(m.stateNode);else if(m.tag!==4&&m.tag!==27&&m.child!==null){m.child.return=m,m=m.child;continue}if(m===t)break e;for(;m.sibling===null;){if(m.return===null||m.return===t)break e;m=m.return}m.sibling.return=m.return,m=m.sibling}t.stateNode=r;e:switch(ut(r,s,a),s){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break e;case"img":a=!0;break e;default:a=!1}a&&wl(t)}}return Be(t),Gr(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==a&&wl(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(u(166));if(e=fe.current,Pa(t)){if(e=t.stateNode,l=t.memoizedProps,a=null,s=ct,s!==null)switch(s.tag){case 27:case 5:a=s.memoizedProps}e[it]=t,e=!!(e.nodeValue===l||a!==null&&a.suppressHydrationWarning===!0||Hm(e.nodeValue,l)),e||Yl(t,!0)}else e=Vi(e).createTextNode(a),e[it]=t,t.stateNode=e}return Be(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(a=Pa(t),l!==null){if(e===null){if(!a)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),e=!1}else l=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(Ot(t),t):(Ot(t),null);if((t.flags&128)!==0)throw Error(u(558))}return Be(t),null;case 13:if(a=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(s=Pa(t),a!==null&&a.dehydrated!==null){if(e===null){if(!s)throw Error(u(318));if(s=t.memoizedState,s=s!==null?s.dehydrated:null,!s)throw Error(u(317));s[it]=t}else ya(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Be(t),s=!1}else s=Ic(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=s),s=!0;if(!s)return t.flags&256?(Ot(t),t):(Ot(t),null)}return Ot(t),(t.flags&128)!==0?(t.lanes=l,t):(l=a!==null,e=e!==null&&e.memoizedState!==null,l&&(a=t.child,s=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(s=a.alternate.memoizedState.cachePool.pool),r=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(r=a.memoizedState.cachePool.pool),r!==s&&(a.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),Mi(t,t.updateQueue),Be(t),null);case 4:return Ee(),e===null&&uo(t.stateNode.containerInfo),Be(t),null;case 10:return Nl(t.type),Be(t),null;case 19:if(L(Ke),a=t.memoizedState,a===null)return Be(t),null;if(s=(t.flags&128)!==0,r=a.rendering,r===null)if(s)is(a,!1);else{if(Ve!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(r=bi(e),r!==null){for(t.flags|=128,is(a,!1),e=r.updateQueue,t.updateQueue=e,Mi(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)pf(l,e),l=l.sibling;return V(Ke,Ke.current&1|2),ve&&yl(t,a.treeForkCount),t.child}e=e.sibling}a.tail!==null&&dt()>Ui&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304)}else{if(!s)if(e=bi(r),e!==null){if(t.flags|=128,s=!0,e=e.updateQueue,t.updateQueue=e,Mi(t,e),is(a,!0),a.tail===null&&a.tailMode==="hidden"&&!r.alternate&&!ve)return Be(t),null}else 2*dt()-a.renderingStartTime>Ui&&l!==536870912&&(t.flags|=128,s=!0,is(a,!1),t.lanes=4194304);a.isBackwards?(r.sibling=t.child,t.child=r):(e=a.last,e!==null?e.sibling=r:t.child=r,a.last=r)}return a.tail!==null?(e=a.tail,a.rendering=e,a.tail=e.sibling,a.renderingStartTime=dt(),e.sibling=null,l=Ke.current,V(Ke,s?l&1|2:l&1),ve&&yl(t,a.treeForkCount),e):(Be(t),null);case 22:case 23:return Ot(t),fr(),a=t.memoizedState!==null,e!==null?e.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(l&536870912)!==0&&(t.flags&128)===0&&(Be(t),t.subtreeFlags&6&&(t.flags|=8192)):Be(t),l=t.updateQueue,l!==null&&Mi(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==l&&(t.flags|=2048),e!==null&&L(Sa),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),Nl(Fe),Be(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function eg(e,t){switch(Wc(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Nl(Fe),Ee(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Me(t),null;case 31:if(t.memoizedState!==null){if(Ot(t),t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ot(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));ya()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return L(Ke),null;case 4:return Ee(),null;case 10:return Nl(t.type),null;case 22:case 23:return Ot(t),fr(),e!==null&&L(Sa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Nl(Fe),null;case 25:return null;default:return null}}function Gd(e,t){switch(Wc(t),t.tag){case 3:Nl(Fe),Ee();break;case 26:case 27:case 5:Me(t);break;case 4:Ee();break;case 31:t.memoizedState!==null&&Ot(t);break;case 13:Ot(t);break;case 19:L(Ke);break;case 10:Nl(t.type);break;case 22:case 23:Ot(t),fr(),e!==null&&L(Sa);break;case 24:Nl(Fe)}}function cs(e,t){try{var l=t.updateQueue,a=l!==null?l.lastEffect:null;if(a!==null){var s=a.next;l=s;do{if((l.tag&e)===e){a=void 0;var r=l.create,m=l.inst;a=r(),m.destroy=a}l=l.next}while(l!==s)}}catch(b){Ae(t,t.return,b)}}function Kl(e,t,l){try{var a=t.updateQueue,s=a!==null?a.lastEffect:null;if(s!==null){var r=s.next;a=r;do{if((a.tag&e)===e){var m=a.inst,b=m.destroy;if(b!==void 0){m.destroy=void 0,s=t;var j=l,C=b;try{C()}catch(R){Ae(s,j,R)}}}a=a.next}while(a!==r)}}catch(R){Ae(t,t.return,R)}}function $d(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{Of(t,l)}catch(a){Ae(e,e.return,a)}}}function Qd(e,t,l){l.props=Ta(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(a){Ae(e,t,a)}}function rs(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var a=e.stateNode;break;case 30:a=e.stateNode;break;default:a=e.stateNode}typeof l=="function"?e.refCleanup=l(a):l.current=a}}catch(s){Ae(e,t,s)}}function rl(e,t){var l=e.ref,a=e.refCleanup;if(l!==null)if(typeof a=="function")try{a()}catch(s){Ae(e,t,s)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(s){Ae(e,t,s)}else l.current=null}function Xd(e){var t=e.type,l=e.memoizedProps,a=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&a.focus();break e;case"img":l.src?a.src=l.src:l.srcSet&&(a.srcset=l.srcSet)}}catch(s){Ae(e,e.return,s)}}function $r(e,t,l){try{var a=e.stateNode;Ng(a,e.type,l,t),a[jt]=t}catch(s){Ae(e,e.return,s)}}function Vd(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&ta(e.type)||e.tag===4}function Qr(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&ta(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Xr(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=gl));else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(Xr(e,t,l),e=e.sibling;e!==null;)Xr(e,t,l),e=e.sibling}function ki(e,t,l){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(a!==4&&(a===27&&ta(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(ki(e,t,l),e=e.sibling;e!==null;)ki(e,t,l),e=e.sibling}function Zd(e){var t=e.stateNode,l=e.memoizedProps;try{for(var a=e.type,s=t.attributes;s.length;)t.removeAttributeNode(s[0]);ut(t,a,l),t[it]=e,t[jt]=l}catch(r){Ae(e,e.return,r)}}var _l=!1,et=!1,Vr=!1,Kd=typeof WeakSet=="function"?WeakSet:Set,st=null;function tg(e,t){if(e=e.containerInfo,ho=Pi,e=sf(e),Bc(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var a=l.getSelection&&l.getSelection();if(a&&a.rangeCount!==0){l=a.anchorNode;var s=a.anchorOffset,r=a.focusNode;a=a.focusOffset;try{l.nodeType,r.nodeType}catch{l=null;break e}var m=0,b=-1,j=-1,C=0,R=0,B=e,M=null;t:for(;;){for(var O;B!==l||s!==0&&B.nodeType!==3||(b=m+s),B!==r||a!==0&&B.nodeType!==3||(j=m+a),B.nodeType===3&&(m+=B.nodeValue.length),(O=B.firstChild)!==null;)M=B,B=O;for(;;){if(B===e)break t;if(M===l&&++C===s&&(b=m),M===r&&++R===a&&(j=m),(O=B.nextSibling)!==null)break;B=M,M=B.parentNode}B=O}l=b===-1||j===-1?null:{start:b,end:j}}else l=null}l=l||{start:0,end:0}}else l=null;for(po={focusedElem:e,selectionRange:l},Pi=!1,st=t;st!==null;)if(t=st,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,st=e;else for(;st!==null;){switch(t=st,r=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l<e.length;l++)s=e[l],s.ref.impl=s.nextImpl;break;case 11:case 15:break;case 1:if((e&1024)!==0&&r!==null){e=void 0,l=t,s=r.memoizedProps,r=r.memoizedState,a=l.stateNode;try{var W=Ta(l.type,s);e=a.getSnapshotBeforeUpdate(W,r),a.__reactInternalSnapshotBeforeUpdate=e}catch(ie){Ae(l,l.return,ie)}}break;case 3:if((e&1024)!==0){if(e=t.stateNode.containerInfo,l=e.nodeType,l===9)bo(e);else if(l===1)switch(e.nodeName){case"HEAD":case"HTML":case"BODY":bo(e);break;default:e.textContent=""}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;default:if((e&1024)!==0)throw Error(u(163))}if(e=t.sibling,e!==null){e.return=t.return,st=e;break}st=t.return}}function Jd(e,t,l){var a=l.flags;switch(l.tag){case 0:case 11:case 15:Al(e,l),a&4&&cs(5,l);break;case 1:if(Al(e,l),a&4)if(e=l.stateNode,t===null)try{e.componentDidMount()}catch(m){Ae(l,l.return,m)}else{var s=Ta(l.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(s,t,e.__reactInternalSnapshotBeforeUpdate)}catch(m){Ae(l,l.return,m)}}a&64&&$d(l),a&512&&rs(l,l.return);break;case 3:if(Al(e,l),a&64&&(e=l.updateQueue,e!==null)){if(t=null,l.child!==null)switch(l.child.tag){case 27:case 5:t=l.child.stateNode;break;case 1:t=l.child.stateNode}try{Of(e,t)}catch(m){Ae(l,l.return,m)}}break;case 27:t===null&&a&4&&Zd(l);case 26:case 5:Al(e,l),t===null&&a&4&&Xd(l),a&512&&rs(l,l.return);break;case 12:Al(e,l);break;case 31:Al(e,l),a&4&&Id(e,l);break;case 13:Al(e,l),a&4&&Pd(e,l),a&64&&(e=l.memoizedState,e!==null&&(e=e.dehydrated,e!==null&&(l=ug.bind(null,l),Cg(e,l))));break;case 22:if(a=l.memoizedState!==null||_l,!a){t=t!==null&&t.memoizedState!==null||et,s=_l;var r=et;_l=a,(et=t)&&!r?Cl(e,l,(l.subtreeFlags&8772)!==0):Al(e,l),_l=s,et=r}break;case 30:break;default:Al(e,l)}}function Wd(e){var t=e.alternate;t!==null&&(e.alternate=null,Wd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&Nc(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var qe=null,St=!1;function Tl(e,t,l){for(l=l.child;l!==null;)Fd(e,t,l),l=l.sibling}function Fd(e,t,l){if(pt&&typeof pt.onCommitFiberUnmount=="function")try{pt.onCommitFiberUnmount(al,l)}catch{}switch(l.tag){case 26:et||rl(l,t),Tl(e,t,l),l.memoizedState?l.memoizedState.count--:l.stateNode&&(l=l.stateNode,l.parentNode.removeChild(l));break;case 27:et||rl(l,t);var a=qe,s=St;ta(l.type)&&(qe=l.stateNode,St=!1),Tl(e,t,l),gs(l.stateNode),qe=a,St=s;break;case 5:et||rl(l,t);case 6:if(a=qe,s=St,qe=null,Tl(e,t,l),qe=a,St=s,qe!==null)if(St)try{(qe.nodeType===9?qe.body:qe.nodeName==="HTML"?qe.ownerDocument.body:qe).removeChild(l.stateNode)}catch(r){Ae(l,t,r)}else try{qe.removeChild(l.stateNode)}catch(r){Ae(l,t,r)}break;case 18:qe!==null&&(St?(e=qe,$m(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.stateNode),jn(e)):$m(qe,l.stateNode));break;case 4:a=qe,s=St,qe=l.stateNode.containerInfo,St=!0,Tl(e,t,l),qe=a,St=s;break;case 0:case 11:case 14:case 15:Kl(2,l,t),et||Kl(4,l,t),Tl(e,t,l);break;case 1:et||(rl(l,t),a=l.stateNode,typeof a.componentWillUnmount=="function"&&Qd(l,t,a)),Tl(e,t,l);break;case 21:Tl(e,t,l);break;case 22:et=(a=et)||l.memoizedState!==null,Tl(e,t,l),et=a;break;default:Tl(e,t,l)}}function Id(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null))){e=e.dehydrated;try{jn(e)}catch(l){Ae(t,t.return,l)}}}function Pd(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null&&(e=e.dehydrated,e!==null))))try{jn(e)}catch(l){Ae(t,t.return,l)}}function lg(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return t===null&&(t=e.stateNode=new Kd),t;case 22:return e=e.stateNode,t=e._retryCache,t===null&&(t=e._retryCache=new Kd),t;default:throw Error(u(435,e.tag))}}function Oi(e,t){var l=lg(e);t.forEach(function(a){if(!l.has(a)){l.add(a);var s=fg.bind(null,e,a);a.then(s,s)}})}function zt(e,t){var l=t.deletions;if(l!==null)for(var a=0;a<l.length;a++){var s=l[a],r=e,m=t,b=m;e:for(;b!==null;){switch(b.tag){case 27:if(ta(b.type)){qe=b.stateNode,St=!1;break e}break;case 5:qe=b.stateNode,St=!1;break e;case 3:case 4:qe=b.stateNode.containerInfo,St=!0;break e}b=b.return}if(qe===null)throw Error(u(160));Fd(r,m,s),qe=null,St=!1,r=s.alternate,r!==null&&(r.return=null),s.return=null}if(t.subtreeFlags&13886)for(t=t.child;t!==null;)em(t,e),t=t.sibling}var It=null;function em(e,t){var l=e.alternate,a=e.flags;switch(e.tag){case 0:case 11:case 14:case 15:zt(t,e),Et(e),a&4&&(Kl(3,e,e.return),cs(3,e),Kl(5,e,e.return));break;case 1:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&64&&_l&&(e=e.updateQueue,e!==null&&(a=e.callbacks,a!==null&&(l=e.shared.hiddenCallbacks,e.shared.hiddenCallbacks=l===null?a:l.concat(a))));break;case 26:var s=It;if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),a&4){var r=l!==null?l.memoizedState:null;if(a=e.memoizedState,l===null)if(a===null)if(e.stateNode===null){e:{a=e.type,l=e.memoizedProps,s=s.ownerDocument||s;t:switch(a){case"title":r=s.getElementsByTagName("title")[0],(!r||r[Rn]||r[it]||r.namespaceURI==="http://www.w3.org/2000/svg"||r.hasAttribute("itemprop"))&&(r=s.createElement(a),s.head.insertBefore(r,s.querySelector("head > title"))),ut(r,a,l),r[it]=e,nt(r),a=r;break e;case"link":var m=eh("link","href",s).get(a+(l.href||""));if(m){for(var b=0;b<m.length;b++)if(r=m[b],r.getAttribute("href")===(l.href==null||l.href===""?null:l.href)&&r.getAttribute("rel")===(l.rel==null?null:l.rel)&&r.getAttribute("title")===(l.title==null?null:l.title)&&r.getAttribute("crossorigin")===(l.crossOrigin==null?null:l.crossOrigin)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;case"meta":if(m=eh("meta","content",s).get(a+(l.content||""))){for(b=0;b<m.length;b++)if(r=m[b],r.getAttribute("content")===(l.content==null?null:""+l.content)&&r.getAttribute("name")===(l.name==null?null:l.name)&&r.getAttribute("property")===(l.property==null?null:l.property)&&r.getAttribute("http-equiv")===(l.httpEquiv==null?null:l.httpEquiv)&&r.getAttribute("charset")===(l.charSet==null?null:l.charSet)){m.splice(b,1);break t}}r=s.createElement(a),ut(r,a,l),s.head.appendChild(r);break;default:throw Error(u(468,a))}r[it]=e,nt(r),a=r}e.stateNode=a}else th(s,e.type,e.stateNode);else e.stateNode=Pm(s,a,e.memoizedProps);else r!==a?(r===null?l.stateNode!==null&&(l=l.stateNode,l.parentNode.removeChild(l)):r.count--,a===null?th(s,e.type,e.stateNode):Pm(s,a,e.memoizedProps)):a===null&&e.stateNode!==null&&$r(e,e.memoizedProps,l.memoizedProps)}break;case 27:zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),l!==null&&a&4&&$r(e,e.memoizedProps,l.memoizedProps);break;case 5:if(zt(t,e),Et(e),a&512&&(et||l===null||rl(l,l.return)),e.flags&32){s=e.stateNode;try{$a(s,"")}catch(W){Ae(e,e.return,W)}}a&4&&e.stateNode!=null&&(s=e.memoizedProps,$r(e,s,l!==null?l.memoizedProps:s)),a&1024&&(Vr=!0);break;case 6:if(zt(t,e),Et(e),a&4){if(e.stateNode===null)throw Error(u(162));a=e.memoizedProps,l=e.stateNode;try{l.nodeValue=a}catch(W){Ae(e,e.return,W)}}break;case 3:if(Ji=null,s=It,It=Zi(t.containerInfo),zt(t,e),It=s,Et(e),a&4&&l!==null&&l.memoizedState.isDehydrated)try{jn(t.containerInfo)}catch(W){Ae(e,e.return,W)}Vr&&(Vr=!1,tm(e));break;case 4:a=It,It=Zi(e.stateNode.containerInfo),zt(t,e),Et(e),It=a;break;case 12:zt(t,e),Et(e);break;case 31:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 13:zt(t,e),Et(e),e.child.flags&8192&&e.memoizedState!==null!=(l!==null&&l.memoizedState!==null)&&(Ri=dt()),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 22:s=e.memoizedState!==null;var j=l!==null&&l.memoizedState!==null,C=_l,R=et;if(_l=C||s,et=R||j,zt(t,e),et=R,_l=C,Et(e),a&8192)e:for(t=e.stateNode,t._visibility=s?t._visibility&-2:t._visibility|1,s&&(l===null||j||_l||et||Aa(e)),l=null,t=e;;){if(t.tag===5||t.tag===26){if(l===null){j=l=t;try{if(r=j.stateNode,s)m=r.style,typeof m.setProperty=="function"?m.setProperty("display","none","important"):m.display="none";else{b=j.stateNode;var B=j.memoizedProps.style,M=B!=null&&B.hasOwnProperty("display")?B.display:null;b.style.display=M==null||typeof M=="boolean"?"":(""+M).trim()}}catch(W){Ae(j,j.return,W)}}}else if(t.tag===6){if(l===null){j=t;try{j.stateNode.nodeValue=s?"":j.memoizedProps}catch(W){Ae(j,j.return,W)}}}else if(t.tag===18){if(l===null){j=t;try{var O=j.stateNode;s?Qm(O,!0):Qm(j.stateNode,!1)}catch(W){Ae(j,j.return,W)}}}else if((t.tag!==22&&t.tag!==23||t.memoizedState===null||t===e)&&t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break e;for(;t.sibling===null;){if(t.return===null||t.return===e)break e;l===t&&(l=null),t=t.return}l===t&&(l=null),t.sibling.return=t.return,t=t.sibling}a&4&&(a=e.updateQueue,a!==null&&(l=a.retryQueue,l!==null&&(a.retryQueue=null,Oi(e,l))));break;case 19:zt(t,e),Et(e),a&4&&(a=e.updateQueue,a!==null&&(e.updateQueue=null,Oi(e,a)));break;case 30:break;case 21:break;default:zt(t,e),Et(e)}}function Et(e){var t=e.flags;if(t&2){try{for(var l,a=e.return;a!==null;){if(Vd(a)){l=a;break}a=a.return}if(l==null)throw Error(u(160));switch(l.tag){case 27:var s=l.stateNode,r=Qr(e);ki(e,r,s);break;case 5:var m=l.stateNode;l.flags&32&&($a(m,""),l.flags&=-33);var b=Qr(e);ki(e,b,m);break;case 3:case 4:var j=l.stateNode.containerInfo,C=Qr(e);Xr(e,C,j);break;default:throw Error(u(161))}}catch(R){Ae(e,e.return,R)}e.flags&=-3}t&4096&&(e.flags&=-4097)}function tm(e){if(e.subtreeFlags&1024)for(e=e.child;e!==null;){var t=e;tm(t),t.tag===5&&t.flags&1024&&t.stateNode.reset(),e=e.sibling}}function Al(e,t){if(t.subtreeFlags&8772)for(t=t.child;t!==null;)Jd(e,t.alternate,t),t=t.sibling}function Aa(e){for(e=e.child;e!==null;){var t=e;switch(t.tag){case 0:case 11:case 14:case 15:Kl(4,t,t.return),Aa(t);break;case 1:rl(t,t.return);var l=t.stateNode;typeof l.componentWillUnmount=="function"&&Qd(t,t.return,l),Aa(t);break;case 27:gs(t.stateNode);case 26:case 5:rl(t,t.return),Aa(t);break;case 22:t.memoizedState===null&&Aa(t);break;case 30:Aa(t);break;default:Aa(t)}e=e.sibling}}function Cl(e,t,l){for(l=l&&(t.subtreeFlags&8772)!==0,t=t.child;t!==null;){var a=t.alternate,s=e,r=t,m=r.flags;switch(r.tag){case 0:case 11:case 15:Cl(s,r,l),cs(4,r);break;case 1:if(Cl(s,r,l),a=r,s=a.stateNode,typeof s.componentDidMount=="function")try{s.componentDidMount()}catch(C){Ae(a,a.return,C)}if(a=r,s=a.updateQueue,s!==null){var b=a.stateNode;try{var j=s.shared.hiddenCallbacks;if(j!==null)for(s.shared.hiddenCallbacks=null,s=0;s<j.length;s++)kf(j[s],b)}catch(C){Ae(a,a.return,C)}}l&&m&64&&$d(r),rs(r,r.return);break;case 27:Zd(r);case 26:case 5:Cl(s,r,l),l&&a===null&&m&4&&Xd(r),rs(r,r.return);break;case 12:Cl(s,r,l);break;case 31:Cl(s,r,l),l&&m&4&&Id(s,r);break;case 13:Cl(s,r,l),l&&m&4&&Pd(s,r);break;case 22:r.memoizedState===null&&Cl(s,r,l),rs(r,r.return);break;case 30:break;default:Cl(s,r,l)}t=t.sibling}}function Zr(e,t){var l=null;e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==l&&(e!=null&&e.refCount++,l!=null&&Kn(l))}function Kr(e,t){e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e))}function Pt(e,t,l,a){if(t.subtreeFlags&10256)for(t=t.child;t!==null;)lm(e,t,l,a),t=t.sibling}function lm(e,t,l,a){var s=t.flags;switch(t.tag){case 0:case 11:case 15:Pt(e,t,l,a),s&2048&&cs(9,t);break;case 1:Pt(e,t,l,a);break;case 3:Pt(e,t,l,a),s&2048&&(e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Kn(e)));break;case 12:if(s&2048){Pt(e,t,l,a),e=t.stateNode;try{var r=t.memoizedProps,m=r.id,b=r.onPostCommit;typeof b=="function"&&b(m,t.alternate===null?"mount":"update",e.passiveEffectDuration,-0)}catch(j){Ae(t,t.return,j)}}else Pt(e,t,l,a);break;case 31:Pt(e,t,l,a);break;case 13:Pt(e,t,l,a);break;case 23:break;case 22:r=t.stateNode,m=t.alternate,t.memoizedState!==null?r._visibility&2?Pt(e,t,l,a):os(e,t):r._visibility&2?Pt(e,t,l,a):(r._visibility|=2,un(e,t,l,a,(t.subtreeFlags&10256)!==0||!1)),s&2048&&Zr(m,t);break;case 24:Pt(e,t,l,a),s&2048&&Kr(t.alternate,t);break;default:Pt(e,t,l,a)}}function un(e,t,l,a,s){for(s=s&&((t.subtreeFlags&10256)!==0||!1),t=t.child;t!==null;){var r=e,m=t,b=l,j=a,C=m.flags;switch(m.tag){case 0:case 11:case 15:un(r,m,b,j,s),cs(8,m);break;case 23:break;case 22:var R=m.stateNode;m.memoizedState!==null?R._visibility&2?un(r,m,b,j,s):os(r,m):(R._visibility|=2,un(r,m,b,j,s)),s&&C&2048&&Zr(m.alternate,m);break;case 24:un(r,m,b,j,s),s&&C&2048&&Kr(m.alternate,m);break;default:un(r,m,b,j,s)}t=t.sibling}}function os(e,t){if(t.subtreeFlags&10256)for(t=t.child;t!==null;){var l=e,a=t,s=a.flags;switch(a.tag){case 22:os(l,a),s&2048&&Zr(a.alternate,a);break;case 24:os(l,a),s&2048&&Kr(a.alternate,a);break;default:os(l,a)}t=t.sibling}}var us=8192;function fn(e,t,l){if(e.subtreeFlags&us)for(e=e.child;e!==null;)am(e,t,l),e=e.sibling}function am(e,t,l){switch(e.tag){case 26:fn(e,t,l),e.flags&us&&e.memoizedState!==null&&Gg(l,It,e.memoizedState,e.memoizedProps);break;case 5:fn(e,t,l);break;case 3:case 4:var a=It;It=Zi(e.stateNode.containerInfo),fn(e,t,l),It=a;break;case 22:e.memoizedState===null&&(a=e.alternate,a!==null&&a.memoizedState!==null?(a=us,us=16777216,fn(e,t,l),us=a):fn(e,t,l));break;default:fn(e,t,l)}}function nm(e){var t=e.alternate;if(t!==null&&(e=t.child,e!==null)){t.child=null;do t=e.sibling,e.sibling=null,e=t;while(e!==null)}}function fs(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}if(e.subtreeFlags&10256)for(e=e.child;e!==null;)sm(e),e=e.sibling}function sm(e){switch(e.tag){case 0:case 11:case 15:fs(e),e.flags&2048&&Kl(9,e,e.return);break;case 3:fs(e);break;case 12:fs(e);break;case 22:var t=e.stateNode;e.memoizedState!==null&&t._visibility&2&&(e.return===null||e.return.tag!==13)?(t._visibility&=-3,Di(e)):fs(e);break;default:fs(e)}}function Di(e){var t=e.deletions;if((e.flags&16)!==0){if(t!==null)for(var l=0;l<t.length;l++){var a=t[l];st=a,im(a,e)}nm(e)}for(e=e.child;e!==null;){switch(t=e,t.tag){case 0:case 11:case 15:Kl(8,t,t.return),Di(t);break;case 22:l=t.stateNode,l._visibility&2&&(l._visibility&=-3,Di(t));break;default:Di(t)}e=e.sibling}}function im(e,t){for(;st!==null;){var l=st;switch(l.tag){case 0:case 11:case 15:Kl(8,l,t);break;case 23:case 22:if(l.memoizedState!==null&&l.memoizedState.cachePool!==null){var a=l.memoizedState.cachePool.pool;a!=null&&a.refCount++}break;case 24:Kn(l.memoizedState.cache)}if(a=l.child,a!==null)a.return=l,st=a;else e:for(l=e;st!==null;){a=st;var s=a.sibling,r=a.return;if(Wd(a),a===l){st=null;break e}if(s!==null){s.return=r,st=s;break e}st=r}}}var ag={getCacheForType:function(e){var t=rt(Fe),l=t.data.get(e);return l===void 0&&(l=e(),t.data.set(e,l)),l},cacheSignal:function(){return rt(Fe).controller.signal}},ng=typeof WeakMap=="function"?WeakMap:Map,we=0,Ue=null,pe=null,ge=0,Te=0,Dt=null,Jl=!1,dn=!1,Jr=!1,Ml=0,Ve=0,Wl=0,Ca=0,Wr=0,Rt=0,mn=0,ds=null,wt=null,Fr=!1,Ri=0,cm=0,Ui=1/0,Hi=null,Fl=null,lt=0,Il=null,hn=null,kl=0,Ir=0,Pr=null,rm=null,ms=0,eo=null;function Ut(){return(we&2)!==0&&ge!==0?ge&-ge:k.T!==null?io():Su()}function om(){if(Rt===0)if((ge&536870912)===0||ve){var e=da;da<<=1,(da&3932160)===0&&(da=262144),Rt=e}else Rt=536870912;return e=kt.current,e!==null&&(e.flags|=32),Rt}function _t(e,t,l){(e===Ue&&(Te===2||Te===9)||e.cancelPendingCommit!==null)&&(pn(e,0),Pl(e,ge,Rt,!1)),Dn(e,l),((we&2)===0||e!==Ue)&&(e===Ue&&((we&2)===0&&(Ca|=l),Ve===4&&Pl(e,ge,Rt,!1)),ol(e))}function um(e,t,l){if((we&6)!==0)throw Error(u(327));var a=!l&&(t&127)===0&&(t&e.expiredLanes)===0||Ul(e,t),s=a?cg(e,t):lo(e,t,!0),r=a;do{if(s===0){dn&&!a&&Pl(e,t,0,!1);break}else{if(l=e.current.alternate,r&&!sg(l)){s=lo(e,t,!1),r=!1;continue}if(s===2){if(r=t,e.errorRecoveryDisabledLanes&r)var m=0;else m=e.pendingLanes&-536870913,m=m!==0?m:m&536870912?536870912:0;if(m!==0){t=m;e:{var b=e;s=ds;var j=b.current.memoizedState.isDehydrated;if(j&&(pn(b,m).flags|=256),m=lo(b,m,!1),m!==2){if(Jr&&!j){b.errorRecoveryDisabledLanes|=r,Ca|=r,s=4;break e}r=wt,wt=s,r!==null&&(wt===null?wt=r:wt.push.apply(wt,r))}s=m}if(r=!1,s!==2)continue}}if(s===1){pn(e,0),Pl(e,t,0,!0);break}e:{switch(a=e,r=s,r){case 0:case 1:throw Error(u(345));case 4:if((t&4194048)!==t)break;case 6:Pl(a,t,Rt,!Jl);break e;case 2:wt=null;break;case 3:case 5:break;default:throw Error(u(329))}if((t&62914560)===t&&(s=Ri+300-dt(),10<s)){if(Pl(a,t,Rt,!Jl),Ua(a,0,!0)!==0)break e;kl=t,a.timeoutHandle=Ym(fm.bind(null,a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,"Throttled",-0,0),s);break e}fm(a,l,wt,Hi,Fr,t,Rt,Ca,mn,Jl,r,null,-0,0)}}break}while(!0);ol(e)}function fm(e,t,l,a,s,r,m,b,j,C,R,B,M,O){if(e.timeoutHandle=-1,B=t.subtreeFlags,B&8192||(B&16785408)===16785408){B={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:gl},am(t,r,B);var W=(r&62914560)===r?Ri-dt():(r&4194048)===r?cm-dt():0;if(W=$g(B,W),W!==null){kl=r,e.cancelPendingCommit=W(vm.bind(null,e,t,r,l,a,s,m,b,j,R,B,null,M,O)),Pl(e,r,m,!C);return}}vm(e,t,r,l,a,s,m,b,j)}function sg(e){for(var t=e;;){var l=t.tag;if((l===0||l===11||l===15)&&t.flags&16384&&(l=t.updateQueue,l!==null&&(l=l.stores,l!==null)))for(var a=0;a<l.length;a++){var s=l[a],r=s.getSnapshot;s=s.value;try{if(!Ct(r(),s))return!1}catch{return!1}}if(l=t.child,t.subtreeFlags&16384&&l!==null)l.return=t,t=l;else{if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return!0;t=t.return}t.sibling.return=t.return,t=t.sibling}}return!0}function Pl(e,t,l,a){t&=~Wr,t&=~Ca,e.suspendedLanes|=t,e.pingedLanes&=~t,a&&(e.warmLanes|=t),a=e.expirationTimes;for(var s=t;0<s;){var r=31-xt(s),m=1<<r;a[r]=-1,s&=~m}l!==0&&yu(e,l,t)}function Li(){return(we&6)===0?(hs(0),!1):!0}function to(){if(pe!==null){if(Te===0)var e=pe.return;else e=pe,jl=ja=null,gr(e),nn=null,Wn=0,e=pe;for(;e!==null;)Gd(e.alternate,e),e=e.return;pe=null}}function pn(e,t){var l=e.timeoutHandle;l!==-1&&(e.timeoutHandle=-1,Eg(l)),l=e.cancelPendingCommit,l!==null&&(e.cancelPendingCommit=null,l()),kl=0,to(),Ue=e,pe=l=vl(e.current,null),ge=t,Te=0,Dt=null,Jl=!1,dn=Ul(e,t),Jr=!1,mn=Rt=Wr=Ca=Wl=Ve=0,wt=ds=null,Fr=!1,(t&8)!==0&&(t|=t&32);var a=e.entangledLanes;if(a!==0)for(e=e.entanglements,a&=t;0<a;){var s=31-xt(a),r=1<<s;t|=e[s],a&=~r}return Ml=t,si(),l}function dm(e,t){de=null,k.H=ns,t===an||t===mi?(t=Tf(),Te=3):t===sr?(t=Tf(),Te=4):Te=t===Or?8:t!==null&&typeof t=="object"&&typeof t.then=="function"?6:1,Dt=t,pe===null&&(Ve=1,_i(e,Yt(t,e.current)))}function mm(){var e=kt.current;return e===null?!0:(ge&4194048)===ge?Xt===null:(ge&62914560)===ge||(ge&536870912)!==0?e===Xt:!1}function hm(){var e=k.H;return k.H=ns,e===null?ns:e}function pm(){var e=k.A;return k.A=ag,e}function Bi(){Ve=4,Jl||(ge&4194048)!==ge&&kt.current!==null||(dn=!0),(Wl&134217727)===0&&(Ca&134217727)===0||Ue===null||Pl(Ue,ge,Rt,!1)}function lo(e,t,l){var a=we;we|=2;var s=hm(),r=pm();(Ue!==e||ge!==t)&&(Hi=null,pn(e,t)),t=!1;var m=Ve;e:do try{if(Te!==0&&pe!==null){var b=pe,j=Dt;switch(Te){case 8:to(),m=6;break e;case 3:case 2:case 9:case 6:kt.current===null&&(t=!0);var C=Te;if(Te=0,Dt=null,xn(e,b,j,C),l&&dn){m=0;break e}break;default:C=Te,Te=0,Dt=null,xn(e,b,j,C)}}ig(),m=Ve;break}catch(R){dm(e,R)}while(!0);return t&&e.shellSuspendCounter++,jl=ja=null,we=a,k.H=s,k.A=r,pe===null&&(Ue=null,ge=0,si()),m}function ig(){for(;pe!==null;)xm(pe)}function cg(e,t){var l=we;we|=2;var a=hm(),s=pm();Ue!==e||ge!==t?(Hi=null,Ui=dt()+500,pn(e,t)):dn=Ul(e,t);e:do try{if(Te!==0&&pe!==null){t=pe;var r=Dt;t:switch(Te){case 1:Te=0,Dt=null,xn(e,t,r,1);break;case 2:case 9:if(wf(r)){Te=0,Dt=null,gm(t);break}t=function(){Te!==2&&Te!==9||Ue!==e||(Te=7),ol(e)},r.then(t,t);break e;case 3:Te=7;break e;case 4:Te=5;break e;case 7:wf(r)?(Te=0,Dt=null,gm(t)):(Te=0,Dt=null,xn(e,t,r,7));break;case 5:var m=null;switch(pe.tag){case 26:m=pe.memoizedState;case 5:case 27:var b=pe;if(m?lh(m):b.stateNode.complete){Te=0,Dt=null;var j=b.sibling;if(j!==null)pe=j;else{var C=b.return;C!==null?(pe=C,qi(C)):pe=null}break t}}Te=0,Dt=null,xn(e,t,r,5);break;case 6:Te=0,Dt=null,xn(e,t,r,6);break;case 8:to(),Ve=6;break e;default:throw Error(u(462))}}rg();break}catch(R){dm(e,R)}while(!0);return jl=ja=null,k.H=a,k.A=s,we=l,pe!==null?0:(Ue=null,ge=0,si(),Ve)}function rg(){for(;pe!==null&&!Tn();)xm(pe)}function xm(e){var t=qd(e.alternate,e,Ml);e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function gm(e){var t=e,l=t.alternate;switch(t.tag){case 15:case 0:t=Dd(l,t,t.pendingProps,t.type,void 0,ge);break;case 11:t=Dd(l,t,t.pendingProps,t.type.render,t.ref,ge);break;case 5:gr(t);default:Gd(l,t),t=pe=pf(t,Ml),t=qd(l,t,Ml)}e.memoizedProps=e.pendingProps,t===null?qi(e):pe=t}function xn(e,t,l,a){jl=ja=null,gr(t),nn=null,Wn=0;var s=t.return;try{if(Wx(e,s,t,l,ge)){Ve=1,_i(e,Yt(l,e.current)),pe=null;return}}catch(r){if(s!==null)throw pe=s,r;Ve=1,_i(e,Yt(l,e.current)),pe=null;return}t.flags&32768?(ve||a===1?e=!0:dn||(ge&536870912)!==0?e=!1:(Jl=e=!0,(a===2||a===9||a===3||a===6)&&(a=kt.current,a!==null&&a.tag===13&&(a.flags|=16384))),bm(t,e)):qi(t)}function qi(e){var t=e;do{if((t.flags&32768)!==0){bm(t,Jl);return}e=t.return;var l=Px(t.alternate,t,Ml);if(l!==null){pe=l;return}if(t=t.sibling,t!==null){pe=t;return}pe=t=e}while(t!==null);Ve===0&&(Ve=5)}function bm(e,t){do{var l=eg(e.alternate,e);if(l!==null){l.flags&=32767,pe=l;return}if(l=e.return,l!==null&&(l.flags|=32768,l.subtreeFlags=0,l.deletions=null),!t&&(e=e.sibling,e!==null)){pe=e;return}pe=e=l}while(e!==null);Ve=6,pe=null}function vm(e,t,l,a,s,r,m,b,j){e.cancelPendingCommit=null;do Yi();while(lt!==0);if((we&6)!==0)throw Error(u(327));if(t!==null){if(t===e.current)throw Error(u(177));if(r=t.lanes|t.childLanes,r|=Qc,Yp(e,l,r,m,b,j),e===Ue&&(pe=Ue=null,ge=0),hn=t,Il=e,kl=l,Ir=r,Pr=s,rm=a,(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?(e.callbackNode=null,e.callbackPriority=0,dg(Da,function(){return zm(),null})):(e.callbackNode=null,e.callbackPriority=0),a=(t.flags&13878)!==0,(t.subtreeFlags&13878)!==0||a){a=k.T,k.T=null,s=Y.p,Y.p=2,m=we,we|=4;try{tg(e,t,l)}finally{we=m,Y.p=s,k.T=a}}lt=1,ym(),jm(),Nm()}}function ym(){if(lt===1){lt=0;var e=Il,t=hn,l=(t.flags&13878)!==0;if((t.subtreeFlags&13878)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{em(t,e);var r=po,m=sf(e.containerInfo),b=r.focusedElem,j=r.selectionRange;if(m!==b&&b&&b.ownerDocument&&nf(b.ownerDocument.documentElement,b)){if(j!==null&&Bc(b)){var C=j.start,R=j.end;if(R===void 0&&(R=C),"selectionStart"in b)b.selectionStart=C,b.selectionEnd=Math.min(R,b.value.length);else{var B=b.ownerDocument||document,M=B&&B.defaultView||window;if(M.getSelection){var O=M.getSelection(),W=b.textContent.length,ie=Math.min(j.start,W),Re=j.end===void 0?ie:Math.min(j.end,W);!O.extend&&ie>Re&&(m=Re,Re=ie,ie=m);var _=af(b,ie),E=af(b,Re);if(_&&E&&(O.rangeCount!==1||O.anchorNode!==_.node||O.anchorOffset!==_.offset||O.focusNode!==E.node||O.focusOffset!==E.offset)){var A=B.createRange();A.setStart(_.node,_.offset),O.removeAllRanges(),ie>Re?(O.addRange(A),O.extend(E.node,E.offset)):(A.setEnd(E.node,E.offset),O.addRange(A))}}}}for(B=[],O=b;O=O.parentNode;)O.nodeType===1&&B.push({element:O,left:O.scrollLeft,top:O.scrollTop});for(typeof b.focus=="function"&&b.focus(),b=0;b<B.length;b++){var H=B[b];H.element.scrollLeft=H.left,H.element.scrollTop=H.top}}Pi=!!ho,po=ho=null}finally{we=s,Y.p=a,k.T=l}}e.current=t,lt=2}}function jm(){if(lt===2){lt=0;var e=Il,t=hn,l=(t.flags&8772)!==0;if((t.subtreeFlags&8772)!==0||l){l=k.T,k.T=null;var a=Y.p;Y.p=2;var s=we;we|=4;try{Jd(e,t.alternate,t)}finally{we=s,Y.p=a,k.T=l}}lt=3}}function Nm(){if(lt===4||lt===3){lt=0,An();var e=Il,t=hn,l=kl,a=rm;(t.subtreeFlags&10256)!==0||(t.flags&10256)!==0?lt=5:(lt=0,hn=Il=null,Sm(e,e.pendingLanes));var s=e.pendingLanes;if(s===0&&(Fl=null),yc(l),t=t.stateNode,pt&&typeof pt.onCommitFiberRoot=="function")try{pt.onCommitFiberRoot(al,t,void 0,(t.current.flags&128)===128)}catch{}if(a!==null){t=k.T,s=Y.p,Y.p=2,k.T=null;try{for(var r=e.onRecoverableError,m=0;m<a.length;m++){var b=a[m];r(b.value,{componentStack:b.stack})}}finally{k.T=t,Y.p=s}}(kl&3)!==0&&Yi(),ol(e),s=e.pendingLanes,(l&261930)!==0&&(s&42)!==0?e===eo?ms++:(ms=0,eo=e):ms=0,hs(0)}}function Sm(e,t){(e.pooledCacheLanes&=t)===0&&(t=e.pooledCache,t!=null&&(e.pooledCache=null,Kn(t)))}function Yi(){return ym(),jm(),Nm(),zm()}function zm(){if(lt!==5)return!1;var e=Il,t=Ir;Ir=0;var l=yc(kl),a=k.T,s=Y.p;try{Y.p=32>l?32:l,k.T=null,l=Pr,Pr=null;var r=Il,m=kl;if(lt=0,hn=Il=null,kl=0,(we&6)!==0)throw Error(u(331));var b=we;if(we|=4,sm(r.current),lm(r,r.current,m,l),we=b,hs(0,!1),pt&&typeof pt.onPostCommitFiberRoot=="function")try{pt.onPostCommitFiberRoot(al,r)}catch{}return!0}finally{Y.p=s,k.T=a,Sm(e,t)}}function Em(e,t,l){t=Yt(l,t),t=kr(e.stateNode,t,2),e=Xl(e,t,2),e!==null&&(Dn(e,2),ol(e))}function Ae(e,t,l){if(e.tag===3)Em(e,e,l);else for(;t!==null;){if(t.tag===3){Em(t,e,l);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Fl===null||!Fl.has(a))){e=Yt(l,e),l=wd(2),a=Xl(t,l,2),a!==null&&(_d(l,a,t,e),Dn(a,2),ol(a));break}}t=t.return}}function ao(e,t,l){var a=e.pingCache;if(a===null){a=e.pingCache=new ng;var s=new Set;a.set(t,s)}else s=a.get(t),s===void 0&&(s=new Set,a.set(t,s));s.has(l)||(Jr=!0,s.add(l),e=og.bind(null,e,t,l),t.then(e,e))}function og(e,t,l){var a=e.pingCache;a!==null&&a.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,Ue===e&&(ge&l)===l&&(Ve===4||Ve===3&&(ge&62914560)===ge&&300>dt()-Ri?(we&2)===0&&pn(e,0):Wr|=l,mn===ge&&(mn=0)),ol(e)}function wm(e,t){t===0&&(t=Zs()),e=ba(e,t),e!==null&&(Dn(e,t),ol(e))}function ug(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),wm(e,l)}function fg(e,t){var l=0;switch(e.tag){case 31:case 13:var a=e.stateNode,s=e.memoizedState;s!==null&&(l=s.retryLane);break;case 19:a=e.stateNode;break;case 22:a=e.stateNode._retryCache;break;default:throw Error(u(314))}a!==null&&a.delete(t),wm(e,l)}function dg(e,t){return Wt(e,t)}var Gi=null,gn=null,no=!1,$i=!1,so=!1,ea=0;function ol(e){e!==gn&&e.next===null&&(gn===null?Gi=gn=e:gn=gn.next=e),$i=!0,no||(no=!0,hg())}function hs(e,t){if(!so&&$i){so=!0;do for(var l=!1,a=Gi;a!==null;){if(e!==0){var s=a.pendingLanes;if(s===0)var r=0;else{var m=a.suspendedLanes,b=a.pingedLanes;r=(1<<31-xt(42|e)+1)-1,r&=s&~(m&~b),r=r&201326741?r&201326741|1:r?r|2:0}r!==0&&(l=!0,Cm(a,r))}else r=ge,r=Ua(a,a===Ue?r:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(r&3)===0||Ul(a,r)||(l=!0,Cm(a,r));a=a.next}while(l);so=!1}}function mg(){_m()}function _m(){$i=no=!1;var e=0;ea!==0&&zg()&&(e=ea);for(var t=dt(),l=null,a=Gi;a!==null;){var s=a.next,r=Tm(a,t);r===0?(a.next=null,l===null?Gi=s:l.next=s,s===null&&(gn=l)):(l=a,(e!==0||(r&3)!==0)&&($i=!0)),a=s}lt!==0&<!==5||hs(e),ea!==0&&(ea=0)}function Tm(e,t){for(var l=e.suspendedLanes,a=e.pingedLanes,s=e.expirationTimes,r=e.pendingLanes&-62914561;0<r;){var m=31-xt(r),b=1<<m,j=s[m];j===-1?((b&l)===0||(b&a)!==0)&&(s[m]=Vs(b,t)):j<=t&&(e.expiredLanes|=b),r&=~b}if(t=Ue,l=ge,l=Ua(e,e===t?l:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a=e.callbackNode,l===0||e===t&&(Te===2||Te===9)||e.cancelPendingCommit!==null)return a!==null&&a!==null&&hl(a),e.callbackNode=null,e.callbackPriority=0;if((l&3)===0||Ul(e,l)){if(t=l&-l,t===e.callbackPriority)return t;switch(a!==null&&hl(a),yc(l)){case 2:case 8:l=Cn;break;case 32:l=Da;break;case 268435456:l=pl;break;default:l=Da}return a=Am.bind(null,e),l=Wt(l,a),e.callbackPriority=t,e.callbackNode=l,t}return a!==null&&a!==null&&hl(a),e.callbackPriority=2,e.callbackNode=null,2}function Am(e,t){if(lt!==0&<!==5)return e.callbackNode=null,e.callbackPriority=0,null;var l=e.callbackNode;if(Yi()&&e.callbackNode!==l)return null;var a=ge;return a=Ua(e,e===Ue?a:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),a===0?null:(um(e,a,t),Tm(e,dt()),e.callbackNode!=null&&e.callbackNode===l?Am.bind(null,e):null)}function Cm(e,t){if(Yi())return null;um(e,t,!0)}function hg(){wg(function(){(we&6)!==0?Wt(Qs,mg):_m()})}function io(){if(ea===0){var e=tn;e===0&&(e=Ra,Ra<<=1,(Ra&261888)===0&&(Ra=256)),ea=e}return ea}function Mm(e){return e==null||typeof e=="symbol"||typeof e=="boolean"?null:typeof e=="function"?e:Fs(""+e)}function km(e,t){var l=t.ownerDocument.createElement("input");return l.name=t.name,l.value=t.value,e.id&&l.setAttribute("form",e.id),t.parentNode.insertBefore(l,t),e=new FormData(e),l.parentNode.removeChild(l),e}function pg(e,t,l,a,s){if(t==="submit"&&l&&l.stateNode===s){var r=Mm((s[jt]||null).action),m=a.submitter;m&&(t=(t=m[jt]||null)?Mm(t.formAction):m.getAttribute("formAction"),t!==null&&(r=t,m=null));var b=new ti("action","action",null,a,s);e.push({event:b,listeners:[{instance:null,listener:function(){if(a.defaultPrevented){if(ea!==0){var j=m?km(s,m):new FormData(s);wr(l,{pending:!0,data:j,method:s.method,action:r},null,j)}}else typeof r=="function"&&(b.preventDefault(),j=m?km(s,m):new FormData(s),wr(l,{pending:!0,data:j,method:s.method,action:r},r,j))},currentTarget:s}]})}}for(var co=0;co<$c.length;co++){var ro=$c[co],xg=ro.toLowerCase(),gg=ro[0].toUpperCase()+ro.slice(1);Ft(xg,"on"+gg)}Ft(of,"onAnimationEnd"),Ft(uf,"onAnimationIteration"),Ft(ff,"onAnimationStart"),Ft("dblclick","onDoubleClick"),Ft("focusin","onFocus"),Ft("focusout","onBlur"),Ft(Ox,"onTransitionRun"),Ft(Dx,"onTransitionStart"),Ft(Rx,"onTransitionCancel"),Ft(df,"onTransitionEnd"),Ya("onMouseEnter",["mouseout","mouseover"]),Ya("onMouseLeave",["mouseout","mouseover"]),Ya("onPointerEnter",["pointerout","pointerover"]),Ya("onPointerLeave",["pointerout","pointerover"]),ha("onChange","change click focusin focusout input keydown keyup selectionchange".split(" ")),ha("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" ")),ha("onBeforeInput",["compositionend","keypress","textInput","paste"]),ha("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" ")),ha("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));var ps="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),bg=new Set("beforetoggle cancel close invalid load scroll scrollend toggle".split(" ").concat(ps));function Om(e,t){t=(t&4)!==0;for(var l=0;l<e.length;l++){var a=e[l],s=a.event;a=a.listeners;e:{var r=void 0;if(t)for(var m=a.length-1;0<=m;m--){var b=a[m],j=b.instance,C=b.currentTarget;if(b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}else for(m=0;m<a.length;m++){if(b=a[m],j=b.instance,C=b.currentTarget,b=b.listener,j!==r&&s.isPropagationStopped())break e;r=b,s.currentTarget=C;try{r(s)}catch(R){ni(R)}s.currentTarget=null,r=j}}}}function xe(e,t){var l=t[jc];l===void 0&&(l=t[jc]=new Set);var a=e+"__bubble";l.has(a)||(Dm(t,e,2,!1),l.add(a))}function oo(e,t,l){var a=0;t&&(a|=4),Dm(l,e,a,t)}var Qi="_reactListening"+Math.random().toString(36).slice(2);function uo(e){if(!e[Qi]){e[Qi]=!0,wu.forEach(function(l){l!=="selectionchange"&&(bg.has(l)||oo(l,!1,e),oo(l,!0,e))});var t=e.nodeType===9?e:e.ownerDocument;t===null||t[Qi]||(t[Qi]=!0,oo("selectionchange",!1,t))}}function Dm(e,t,l,a){switch(oh(t)){case 2:var s=Vg;break;case 8:s=Zg;break;default:s=wo}l=s.bind(null,t,l,e),s=void 0,!Cc||t!=="touchstart"&&t!=="touchmove"&&t!=="wheel"||(s=!0),a?s!==void 0?e.addEventListener(t,l,{capture:!0,passive:s}):e.addEventListener(t,l,!0):s!==void 0?e.addEventListener(t,l,{passive:s}):e.addEventListener(t,l,!1)}function fo(e,t,l,a,s){var r=a;if((t&1)===0&&(t&2)===0&&a!==null)e:for(;;){if(a===null)return;var m=a.tag;if(m===3||m===4){var b=a.stateNode.containerInfo;if(b===s)break;if(m===4)for(m=a.return;m!==null;){var j=m.tag;if((j===3||j===4)&&m.stateNode.containerInfo===s)return;m=m.return}for(;b!==null;){if(m=La(b),m===null)return;if(j=m.tag,j===5||j===6||j===26||j===27){a=r=m;continue e}b=b.parentNode}}a=a.return}Lu(function(){var C=r,R=Tc(l),B=[];e:{var M=mf.get(e);if(M!==void 0){var O=ti,W=e;switch(e){case"keypress":if(Ps(l)===0)break e;case"keydown":case"keyup":O=fx;break;case"focusin":W="focus",O=Dc;break;case"focusout":W="blur",O=Dc;break;case"beforeblur":case"afterblur":O=Dc;break;case"click":if(l.button===2)break e;case"auxclick":case"dblclick":case"mousedown":case"mousemove":case"mouseup":case"mouseout":case"mouseover":case"contextmenu":O=Yu;break;case"drag":case"dragend":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"dragstart":case"drop":O=Pp;break;case"touchcancel":case"touchend":case"touchmove":case"touchstart":O=hx;break;case of:case uf:case ff:O=lx;break;case df:O=xx;break;case"scroll":case"scrollend":O=Fp;break;case"wheel":O=bx;break;case"copy":case"cut":case"paste":O=nx;break;case"gotpointercapture":case"lostpointercapture":case"pointercancel":case"pointerdown":case"pointermove":case"pointerout":case"pointerover":case"pointerup":O=$u;break;case"toggle":case"beforetoggle":O=yx}var ie=(t&4)!==0,Re=!ie&&(e==="scroll"||e==="scrollend"),_=ie?M!==null?M+"Capture":null:M;ie=[];for(var E=C,A;E!==null;){var H=E;if(A=H.stateNode,H=H.tag,H!==5&&H!==26&&H!==27||A===null||_===null||(H=Hn(E,_),H!=null&&ie.push(xs(E,H,A))),Re)break;E=E.return}0<ie.length&&(M=new O(M,W,null,l,R),B.push({event:M,listeners:ie}))}}if((t&7)===0){e:{if(M=e==="mouseover"||e==="pointerover",O=e==="mouseout"||e==="pointerout",M&&l!==_c&&(W=l.relatedTarget||l.fromElement)&&(La(W)||W[Ha]))break e;if((O||M)&&(M=R.window===R?R:(M=R.ownerDocument)?M.defaultView||M.parentWindow:window,O?(W=l.relatedTarget||l.toElement,O=C,W=W?La(W):null,W!==null&&(Re=d(W),ie=W.tag,W!==Re||ie!==5&&ie!==27&&ie!==6)&&(W=null)):(O=null,W=C),O!==W)){if(ie=Yu,H="onMouseLeave",_="onMouseEnter",E="mouse",(e==="pointerout"||e==="pointerover")&&(ie=$u,H="onPointerLeave",_="onPointerEnter",E="pointer"),Re=O==null?M:Un(O),A=W==null?M:Un(W),M=new ie(H,E+"leave",O,l,R),M.target=Re,M.relatedTarget=A,H=null,La(R)===C&&(ie=new ie(_,E+"enter",W,l,R),ie.target=A,ie.relatedTarget=Re,H=ie),Re=H,O&&W)t:{for(ie=vg,_=O,E=W,A=0,H=_;H;H=ie(H))A++;H=0;for(var ne=E;ne;ne=ie(ne))H++;for(;0<A-H;)_=ie(_),A--;for(;0<H-A;)E=ie(E),H--;for(;A--;){if(_===E||E!==null&&_===E.alternate){ie=_;break t}_=ie(_),E=ie(E)}ie=null}else ie=null;O!==null&&Rm(B,M,O,ie,!1),W!==null&&Re!==null&&Rm(B,Re,W,ie,!0)}}e:{if(M=C?Un(C):window,O=M.nodeName&&M.nodeName.toLowerCase(),O==="select"||O==="input"&&M.type==="file")var Ne=Fu;else if(Ju(M))if(Iu)Ne=Cx;else{Ne=Tx;var F=_x}else O=M.nodeName,!O||O.toLowerCase()!=="input"||M.type!=="checkbox"&&M.type!=="radio"?C&&wc(C.elementType)&&(Ne=Fu):Ne=Ax;if(Ne&&(Ne=Ne(e,C))){Wu(B,Ne,l,R);break e}F&&F(e,M,C),e==="focusout"&&C&&M.type==="number"&&C.memoizedProps.value!=null&&Ec(M,"number",M.value)}switch(F=C?Un(C):window,e){case"focusin":(Ju(F)||F.contentEditable==="true")&&(Za=F,qc=C,Xn=null);break;case"focusout":Xn=qc=Za=null;break;case"mousedown":Yc=!0;break;case"contextmenu":case"mouseup":case"dragend":Yc=!1,cf(B,l,R);break;case"selectionchange":if(kx)break;case"keydown":case"keyup":cf(B,l,R)}var me;if(Uc)e:{switch(e){case"compositionstart":var be="onCompositionStart";break e;case"compositionend":be="onCompositionEnd";break e;case"compositionupdate":be="onCompositionUpdate";break e}be=void 0}else Va?Zu(e,l)&&(be="onCompositionEnd"):e==="keydown"&&l.keyCode===229&&(be="onCompositionStart");be&&(Qu&&l.locale!=="ko"&&(Va||be!=="onCompositionStart"?be==="onCompositionEnd"&&Va&&(me=Bu()):(Ll=R,Mc="value"in Ll?Ll.value:Ll.textContent,Va=!0)),F=Xi(C,be),0<F.length&&(be=new Gu(be,e,null,l,R),B.push({event:be,listeners:F}),me?be.data=me:(me=Ku(l),me!==null&&(be.data=me)))),(me=Nx?Sx(e,l):zx(e,l))&&(be=Xi(C,"onBeforeInput"),0<be.length&&(F=new Gu("onBeforeInput","beforeinput",null,l,R),B.push({event:F,listeners:be}),F.data=me)),pg(B,e,C,l,R)}Om(B,t)})}function xs(e,t,l){return{instance:e,listener:t,currentTarget:l}}function Xi(e,t){for(var l=t+"Capture",a=[];e!==null;){var s=e,r=s.stateNode;if(s=s.tag,s!==5&&s!==26&&s!==27||r===null||(s=Hn(e,l),s!=null&&a.unshift(xs(e,s,r)),s=Hn(e,t),s!=null&&a.push(xs(e,s,r))),e.tag===3)return a;e=e.return}return[]}function vg(e){if(e===null)return null;do e=e.return;while(e&&e.tag!==5&&e.tag!==27);return e||null}function Rm(e,t,l,a,s){for(var r=t._reactName,m=[];l!==null&&l!==a;){var b=l,j=b.alternate,C=b.stateNode;if(b=b.tag,j!==null&&j===a)break;b!==5&&b!==26&&b!==27||C===null||(j=C,s?(C=Hn(l,r),C!=null&&m.unshift(xs(l,C,j))):s||(C=Hn(l,r),C!=null&&m.push(xs(l,C,j)))),l=l.return}m.length!==0&&e.push({event:t,listeners:m})}var yg=/\r\n?/g,jg=/\u0000|\uFFFD/g;function Um(e){return(typeof e=="string"?e:""+e).replace(yg,` +`).replace(jg,"")}function Hm(e,t){return t=Um(t),Um(e)===t}function De(e,t,l,a,s,r){switch(l){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||$a(e,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&$a(e,""+a);break;case"className":Js(e,"class",a);break;case"tabIndex":Js(e,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Js(e,l,a);break;case"style":Uu(e,a,r);break;case"data":if(t!=="object"){Js(e,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||l!=="href")){e.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){e.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof r=="function"&&(l==="formAction"?(t!=="input"&&De(e,t,"name",s.name,s,null),De(e,t,"formEncType",s.formEncType,s,null),De(e,t,"formMethod",s.formMethod,s,null),De(e,t,"formTarget",s.formTarget,s,null)):(De(e,t,"encType",s.encType,s,null),De(e,t,"method",s.method,s,null),De(e,t,"target",s.target,s,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=Fs(""+a),e.setAttribute(l,a);break;case"onClick":a!=null&&(e.onclick=gl);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"multiple":e.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":e.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){e.removeAttribute("xlink:href");break}l=Fs(""+a),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""+a):e.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""):e.removeAttribute(l);break;case"capture":case"download":a===!0?e.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,a):e.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?e.setAttribute(l,a):e.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?e.removeAttribute(l):e.setAttribute(l,a);break;case"popover":xe("beforetoggle",e),xe("toggle",e),Ks(e,"popover",a);break;case"xlinkActuate":xl(e,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":xl(e,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":xl(e,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":xl(e,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":xl(e,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":xl(e,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":xl(e,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":xl(e,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":xl(e,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Ks(e,"is",a);break;case"innerText":case"textContent":break;default:(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")&&(l=Jp.get(l)||l,Ks(e,l,a))}}function mo(e,t,l,a,s,r){switch(l){case"style":Uu(e,a,r);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(u(61));if(l=a.__html,l!=null){if(s.children!=null)throw Error(u(60));e.innerHTML=l}}break;case"children":typeof a=="string"?$a(e,a):(typeof a=="number"||typeof a=="bigint")&&$a(e,""+a);break;case"onScroll":a!=null&&xe("scroll",e);break;case"onScrollEnd":a!=null&&xe("scrollend",e);break;case"onClick":a!=null&&(e.onclick=gl);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"innerHTML":case"ref":break;case"innerText":case"textContent":break;default:if(!_u.hasOwnProperty(l))e:{if(l[0]==="o"&&l[1]==="n"&&(s=l.endsWith("Capture"),t=l.slice(2,s?l.length-7:void 0),r=e[jt]||null,r=r!=null?r[l]:null,typeof r=="function"&&e.removeEventListener(t,r,s),typeof a=="function")){typeof r!="function"&&r!==null&&(l in e?e[l]=null:e.hasAttribute(l)&&e.removeAttribute(l)),e.addEventListener(t,a,s);break e}l in e?e[l]=a:a===!0?e.setAttribute(l,""):Ks(e,l,a)}}}function ut(e,t,l){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"img":xe("error",e),xe("load",e);var a=!1,s=!1,r;for(r in l)if(l.hasOwnProperty(r)){var m=l[r];if(m!=null)switch(r){case"src":a=!0;break;case"srcSet":s=!0;break;case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,r,m,l,null)}}s&&De(e,t,"srcSet",l.srcSet,l,null),a&&De(e,t,"src",l.src,l,null);return;case"input":xe("invalid",e);var b=r=m=s=null,j=null,C=null;for(a in l)if(l.hasOwnProperty(a)){var R=l[a];if(R!=null)switch(a){case"name":s=R;break;case"type":m=R;break;case"checked":j=R;break;case"defaultChecked":C=R;break;case"value":r=R;break;case"defaultValue":b=R;break;case"children":case"dangerouslySetInnerHTML":if(R!=null)throw Error(u(137,t));break;default:De(e,t,a,R,l,null)}}ku(e,r,b,j,C,m,s,!1);return;case"select":xe("invalid",e),a=m=r=null;for(s in l)if(l.hasOwnProperty(s)&&(b=l[s],b!=null))switch(s){case"value":r=b;break;case"defaultValue":m=b;break;case"multiple":a=b;default:De(e,t,s,b,l,null)}t=r,l=m,e.multiple=!!a,t!=null?Ga(e,!!a,t,!1):l!=null&&Ga(e,!!a,l,!0);return;case"textarea":xe("invalid",e),r=s=a=null;for(m in l)if(l.hasOwnProperty(m)&&(b=l[m],b!=null))switch(m){case"value":a=b;break;case"defaultValue":s=b;break;case"children":r=b;break;case"dangerouslySetInnerHTML":if(b!=null)throw Error(u(91));break;default:De(e,t,m,b,l,null)}Du(e,a,s,r);return;case"option":for(j in l)if(l.hasOwnProperty(j)&&(a=l[j],a!=null))switch(j){case"selected":e.selected=a&&typeof a!="function"&&typeof a!="symbol";break;default:De(e,t,j,a,l,null)}return;case"dialog":xe("beforetoggle",e),xe("toggle",e),xe("cancel",e),xe("close",e);break;case"iframe":case"object":xe("load",e);break;case"video":case"audio":for(a=0;a<ps.length;a++)xe(ps[a],e);break;case"image":xe("error",e),xe("load",e);break;case"details":xe("toggle",e);break;case"embed":case"source":case"link":xe("error",e),xe("load",e);case"area":case"base":case"br":case"col":case"hr":case"keygen":case"meta":case"param":case"track":case"wbr":case"menuitem":for(C in l)if(l.hasOwnProperty(C)&&(a=l[C],a!=null))switch(C){case"children":case"dangerouslySetInnerHTML":throw Error(u(137,t));default:De(e,t,C,a,l,null)}return;default:if(wc(t)){for(R in l)l.hasOwnProperty(R)&&(a=l[R],a!==void 0&&mo(e,t,R,a,l,void 0));return}}for(b in l)l.hasOwnProperty(b)&&(a=l[b],a!=null&&De(e,t,b,a,l,null))}function Ng(e,t,l,a){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"input":var s=null,r=null,m=null,b=null,j=null,C=null,R=null;for(O in l){var B=l[O];if(l.hasOwnProperty(O)&&B!=null)switch(O){case"checked":break;case"value":break;case"defaultValue":j=B;default:a.hasOwnProperty(O)||De(e,t,O,null,a,B)}}for(var M in a){var O=a[M];if(B=l[M],a.hasOwnProperty(M)&&(O!=null||B!=null))switch(M){case"type":r=O;break;case"name":s=O;break;case"checked":C=O;break;case"defaultChecked":R=O;break;case"value":m=O;break;case"defaultValue":b=O;break;case"children":case"dangerouslySetInnerHTML":if(O!=null)throw Error(u(137,t));break;default:O!==B&&De(e,t,M,O,a,B)}}zc(e,m,b,j,C,R,r,s);return;case"select":O=m=b=M=null;for(r in l)if(j=l[r],l.hasOwnProperty(r)&&j!=null)switch(r){case"value":break;case"multiple":O=j;default:a.hasOwnProperty(r)||De(e,t,r,null,a,j)}for(s in a)if(r=a[s],j=l[s],a.hasOwnProperty(s)&&(r!=null||j!=null))switch(s){case"value":M=r;break;case"defaultValue":b=r;break;case"multiple":m=r;default:r!==j&&De(e,t,s,r,a,j)}t=b,l=m,a=O,M!=null?Ga(e,!!l,M,!1):!!a!=!!l&&(t!=null?Ga(e,!!l,t,!0):Ga(e,!!l,l?[]:"",!1));return;case"textarea":O=M=null;for(b in l)if(s=l[b],l.hasOwnProperty(b)&&s!=null&&!a.hasOwnProperty(b))switch(b){case"value":break;case"children":break;default:De(e,t,b,null,a,s)}for(m in a)if(s=a[m],r=l[m],a.hasOwnProperty(m)&&(s!=null||r!=null))switch(m){case"value":M=s;break;case"defaultValue":O=s;break;case"children":break;case"dangerouslySetInnerHTML":if(s!=null)throw Error(u(91));break;default:s!==r&&De(e,t,m,s,a,r)}Ou(e,M,O);return;case"option":for(var W in l)if(M=l[W],l.hasOwnProperty(W)&&M!=null&&!a.hasOwnProperty(W))switch(W){case"selected":e.selected=!1;break;default:De(e,t,W,null,a,M)}for(j in a)if(M=a[j],O=l[j],a.hasOwnProperty(j)&&M!==O&&(M!=null||O!=null))switch(j){case"selected":e.selected=M&&typeof M!="function"&&typeof M!="symbol";break;default:De(e,t,j,M,a,O)}return;case"img":case"link":case"area":case"base":case"br":case"col":case"embed":case"hr":case"keygen":case"meta":case"param":case"source":case"track":case"wbr":case"menuitem":for(var ie in l)M=l[ie],l.hasOwnProperty(ie)&&M!=null&&!a.hasOwnProperty(ie)&&De(e,t,ie,null,a,M);for(C in a)if(M=a[C],O=l[C],a.hasOwnProperty(C)&&M!==O&&(M!=null||O!=null))switch(C){case"children":case"dangerouslySetInnerHTML":if(M!=null)throw Error(u(137,t));break;default:De(e,t,C,M,a,O)}return;default:if(wc(t)){for(var Re in l)M=l[Re],l.hasOwnProperty(Re)&&M!==void 0&&!a.hasOwnProperty(Re)&&mo(e,t,Re,void 0,a,M);for(R in a)M=a[R],O=l[R],!a.hasOwnProperty(R)||M===O||M===void 0&&O===void 0||mo(e,t,R,M,a,O);return}}for(var _ in l)M=l[_],l.hasOwnProperty(_)&&M!=null&&!a.hasOwnProperty(_)&&De(e,t,_,null,a,M);for(B in a)M=a[B],O=l[B],!a.hasOwnProperty(B)||M===O||M==null&&O==null||De(e,t,B,M,a,O)}function Lm(e){switch(e){case"css":case"script":case"font":case"img":case"image":case"input":case"link":return!0;default:return!1}}function Sg(){if(typeof performance.getEntriesByType=="function"){for(var e=0,t=0,l=performance.getEntriesByType("resource"),a=0;a<l.length;a++){var s=l[a],r=s.transferSize,m=s.initiatorType,b=s.duration;if(r&&b&&Lm(m)){for(m=0,b=s.responseEnd,a+=1;a<l.length;a++){var j=l[a],C=j.startTime;if(C>b)break;var R=j.transferSize,B=j.initiatorType;R&&Lm(B)&&(j=j.responseEnd,m+=R*(j<b?1:(b-C)/(j-C)))}if(--a,t+=8*(r+m)/(s.duration/1e3),e++,10<e)break}}if(0<e)return t/e/1e6}return navigator.connection&&(e=navigator.connection.downlink,typeof e=="number")?e:5}var ho=null,po=null;function Vi(e){return e.nodeType===9?e:e.ownerDocument}function Bm(e){switch(e){case"http://www.w3.org/2000/svg":return 1;case"http://www.w3.org/1998/Math/MathML":return 2;default:return 0}}function qm(e,t){if(e===0)switch(t){case"svg":return 1;case"math":return 2;default:return 0}return e===1&&t==="foreignObject"?0:e}function xo(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.children=="bigint"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var go=null;function zg(){var e=window.event;return e&&e.type==="popstate"?e===go?!1:(go=e,!0):(go=null,!1)}var Ym=typeof setTimeout=="function"?setTimeout:void 0,Eg=typeof clearTimeout=="function"?clearTimeout:void 0,Gm=typeof Promise=="function"?Promise:void 0,wg=typeof queueMicrotask=="function"?queueMicrotask:typeof Gm<"u"?function(e){return Gm.resolve(null).then(e).catch(_g)}:Ym;function _g(e){setTimeout(function(){throw e})}function ta(e){return e==="head"}function $m(e,t){var l=t,a=0;do{var s=l.nextSibling;if(e.removeChild(l),s&&s.nodeType===8)if(l=s.data,l==="/$"||l==="/&"){if(a===0){e.removeChild(s),jn(t);return}a--}else if(l==="$"||l==="$?"||l==="$~"||l==="$!"||l==="&")a++;else if(l==="html")gs(e.ownerDocument.documentElement);else if(l==="head"){l=e.ownerDocument.head,gs(l);for(var r=l.firstChild;r;){var m=r.nextSibling,b=r.nodeName;r[Rn]||b==="SCRIPT"||b==="STYLE"||b==="LINK"&&r.rel.toLowerCase()==="stylesheet"||l.removeChild(r),r=m}}else l==="body"&&gs(e.ownerDocument.body);l=s}while(l);jn(t)}function Qm(e,t){var l=e;e=0;do{var a=l.nextSibling;if(l.nodeType===1?t?(l._stashedDisplay=l.style.display,l.style.display="none"):(l.style.display=l._stashedDisplay||"",l.getAttribute("style")===""&&l.removeAttribute("style")):l.nodeType===3&&(t?(l._stashedText=l.nodeValue,l.nodeValue=""):l.nodeValue=l._stashedText||""),a&&a.nodeType===8)if(l=a.data,l==="/$"){if(e===0)break;e--}else l!=="$"&&l!=="$?"&&l!=="$~"&&l!=="$!"||e++;l=a}while(l)}function bo(e){var t=e.firstChild;for(t&&t.nodeType===10&&(t=t.nextSibling);t;){var l=t;switch(t=t.nextSibling,l.nodeName){case"HTML":case"HEAD":case"BODY":bo(l),Nc(l);continue;case"SCRIPT":case"STYLE":continue;case"LINK":if(l.rel.toLowerCase()==="stylesheet")continue}e.removeChild(l)}}function Tg(e,t,l,a){for(;e.nodeType===1;){var s=l;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!a&&(e.nodeName!=="INPUT"||e.type!=="hidden"))break}else if(a){if(!e[Rn])switch(t){case"meta":if(!e.hasAttribute("itemprop"))break;return e;case"link":if(r=e.getAttribute("rel"),r==="stylesheet"&&e.hasAttribute("data-precedence"))break;if(r!==s.rel||e.getAttribute("href")!==(s.href==null||s.href===""?null:s.href)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin)||e.getAttribute("title")!==(s.title==null?null:s.title))break;return e;case"style":if(e.hasAttribute("data-precedence"))break;return e;case"script":if(r=e.getAttribute("src"),(r!==(s.src==null?null:s.src)||e.getAttribute("type")!==(s.type==null?null:s.type)||e.getAttribute("crossorigin")!==(s.crossOrigin==null?null:s.crossOrigin))&&r&&e.hasAttribute("async")&&!e.hasAttribute("itemprop"))break;return e;default:return e}}else if(t==="input"&&e.type==="hidden"){var r=s.name==null?null:""+s.name;if(s.type==="hidden"&&e.getAttribute("name")===r)return e}else return e;if(e=Vt(e.nextSibling),e===null)break}return null}function Ag(e,t,l){if(t==="")return null;for(;e.nodeType!==3;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!l||(e=Vt(e.nextSibling),e===null))return null;return e}function Xm(e,t){for(;e.nodeType!==8;)if((e.nodeType!==1||e.nodeName!=="INPUT"||e.type!=="hidden")&&!t||(e=Vt(e.nextSibling),e===null))return null;return e}function vo(e){return e.data==="$?"||e.data==="$~"}function yo(e){return e.data==="$!"||e.data==="$?"&&e.ownerDocument.readyState!=="loading"}function Cg(e,t){var l=e.ownerDocument;if(e.data==="$~")e._reactRetry=t;else if(e.data!=="$?"||l.readyState!=="loading")t();else{var a=function(){t(),l.removeEventListener("DOMContentLoaded",a)};l.addEventListener("DOMContentLoaded",a),e._reactRetry=a}}function Vt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?"||t==="$~"||t==="&"||t==="F!"||t==="F")break;if(t==="/$"||t==="/&")return null}}return e}var jo=null;function Vm(e){e=e.nextSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="/$"||l==="/&"){if(t===0)return Vt(e.nextSibling);t--}else l!=="$"&&l!=="$!"&&l!=="$?"&&l!=="$~"&&l!=="&"||t++}e=e.nextSibling}return null}function Zm(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var l=e.data;if(l==="$"||l==="$!"||l==="$?"||l==="$~"||l==="&"){if(t===0)return e;t--}else l!=="/$"&&l!=="/&"||t++}e=e.previousSibling}return null}function Km(e,t,l){switch(t=Vi(l),e){case"html":if(e=t.documentElement,!e)throw Error(u(452));return e;case"head":if(e=t.head,!e)throw Error(u(453));return e;case"body":if(e=t.body,!e)throw Error(u(454));return e;default:throw Error(u(451))}}function gs(e){for(var t=e.attributes;t.length;)e.removeAttributeNode(t[0]);Nc(e)}var Zt=new Map,Jm=new Set;function Zi(e){return typeof e.getRootNode=="function"?e.getRootNode():e.nodeType===9?e:e.ownerDocument}var Ol=Y.d;Y.d={f:Mg,r:kg,D:Og,C:Dg,L:Rg,m:Ug,X:Lg,S:Hg,M:Bg};function Mg(){var e=Ol.f(),t=Li();return e||t}function kg(e){var t=Ba(e);t!==null&&t.tag===5&&t.type==="form"?dd(t):Ol.r(e)}var bn=typeof document>"u"?null:document;function Wm(e,t,l){var a=bn;if(a&&typeof t=="string"&&t){var s=Bt(t);s='link[rel="'+e+'"][href="'+s+'"]',typeof l=="string"&&(s+='[crossorigin="'+l+'"]'),Jm.has(s)||(Jm.add(s),e={rel:e,crossOrigin:l,href:t},a.querySelector(s)===null&&(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Og(e){Ol.D(e),Wm("dns-prefetch",e,null)}function Dg(e,t){Ol.C(e,t),Wm("preconnect",e,t)}function Rg(e,t,l){Ol.L(e,t,l);var a=bn;if(a&&e&&t){var s='link[rel="preload"][as="'+Bt(t)+'"]';t==="image"&&l&&l.imageSrcSet?(s+='[imagesrcset="'+Bt(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(s+='[imagesizes="'+Bt(l.imageSizes)+'"]')):s+='[href="'+Bt(e)+'"]';var r=s;switch(t){case"style":r=vn(e);break;case"script":r=yn(e)}Zt.has(r)||(e=y({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),Zt.set(r,e),a.querySelector(s)!==null||t==="style"&&a.querySelector(bs(r))||t==="script"&&a.querySelector(vs(r))||(t=a.createElement("link"),ut(t,"link",e),nt(t),a.head.appendChild(t)))}}function Ug(e,t){Ol.m(e,t);var l=bn;if(l&&e){var a=t&&typeof t.as=="string"?t.as:"script",s='link[rel="modulepreload"][as="'+Bt(a)+'"][href="'+Bt(e)+'"]',r=s;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":r=yn(e)}if(!Zt.has(r)&&(e=y({rel:"modulepreload",href:e},t),Zt.set(r,e),l.querySelector(s)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(vs(r)))return}a=l.createElement("link"),ut(a,"link",e),nt(a),l.head.appendChild(a)}}}function Hg(e,t,l){Ol.S(e,t,l);var a=bn;if(a&&e){var s=qa(a).hoistableStyles,r=vn(e);t=t||"default";var m=s.get(r);if(!m){var b={loading:0,preload:null};if(m=a.querySelector(bs(r)))b.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":t},l),(l=Zt.get(r))&&No(e,l);var j=m=a.createElement("link");nt(j),ut(j,"link",e),j._p=new Promise(function(C,R){j.onload=C,j.onerror=R}),j.addEventListener("load",function(){b.loading|=1}),j.addEventListener("error",function(){b.loading|=2}),b.loading|=4,Ki(m,t,a)}m={type:"stylesheet",instance:m,count:1,state:b},s.set(r,m)}}}function Lg(e,t){Ol.X(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Bg(e,t){Ol.M(e,t);var l=bn;if(l&&e){var a=qa(l).hoistableScripts,s=yn(e),r=a.get(s);r||(r=l.querySelector(vs(s)),r||(e=y({src:e,async:!0,type:"module"},t),(t=Zt.get(s))&&So(e,t),r=l.createElement("script"),nt(r),ut(r,"link",e),l.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},a.set(s,r))}}function Fm(e,t,l,a){var s=(s=fe.current)?Zi(s):null;if(!s)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=vn(l.href),l=qa(s).hoistableStyles,a=l.get(t),a||(a={type:"style",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=vn(l.href);var r=qa(s).hoistableStyles,m=r.get(e);if(m||(s=s.ownerDocument||s,m={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},r.set(e,m),(r=s.querySelector(bs(e)))&&!r._p&&(m.instance=r,m.state.loading=5),Zt.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},Zt.set(e,l),r||qg(s,e,l,m.state))),t&&a===null)throw Error(u(528,""));return m}if(t&&a!==null)throw Error(u(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=yn(l),l=qa(s).hoistableScripts,a=l.get(t),a||(a={type:"script",instance:null,count:0,state:null},l.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function vn(e){return'href="'+Bt(e)+'"'}function bs(e){return'link[rel="stylesheet"]['+e+"]"}function Im(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function qg(e,t,l,a){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=e.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),ut(t,"link",l),nt(t),e.head.appendChild(t))}function yn(e){return'[src="'+Bt(e)+'"]'}function vs(e){return"script[async]"+e}function Pm(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var a=e.querySelector('style[data-href~="'+Bt(l.href)+'"]');if(a)return t.instance=a,nt(a),a;var s=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return a=(e.ownerDocument||e).createElement("style"),nt(a),ut(a,"style",s),Ki(a,l.precedence,e),t.instance=a;case"stylesheet":s=vn(l.href);var r=e.querySelector(bs(s));if(r)return t.state.loading|=4,t.instance=r,nt(r),r;a=Im(l),(s=Zt.get(s))&&No(a,s),r=(e.ownerDocument||e).createElement("link"),nt(r);var m=r;return m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),t.state.loading|=4,Ki(r,l.precedence,e),t.instance=r;case"script":return r=yn(l.src),(s=e.querySelector(vs(r)))?(t.instance=s,nt(s),s):(a=l,(s=Zt.get(r))&&(a=y({},l),So(a,s)),e=e.ownerDocument||e,s=e.createElement("script"),nt(s),ut(s,"link",a),e.head.appendChild(s),t.instance=s);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,Ki(a,l.precedence,e));return t.instance}function Ki(e,t,l){for(var a=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),s=a.length?a[a.length-1]:null,r=s,m=0;m<a.length;m++){var b=a[m];if(b.dataset.precedence===t)r=b;else if(r!==s)break}r?r.parentNode.insertBefore(e,r.nextSibling):(t=l.nodeType===9?l.head:l,t.insertBefore(e,t.firstChild))}function No(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.title==null&&(e.title=t.title)}function So(e,t){e.crossOrigin==null&&(e.crossOrigin=t.crossOrigin),e.referrerPolicy==null&&(e.referrerPolicy=t.referrerPolicy),e.integrity==null&&(e.integrity=t.integrity)}var Ji=null;function eh(e,t,l){if(Ji===null){var a=new Map,s=Ji=new Map;s.set(l,a)}else s=Ji,a=s.get(l),a||(a=new Map,s.set(l,a));if(a.has(e))return a;for(a.set(e,null),l=l.getElementsByTagName(e),s=0;s<l.length;s++){var r=l[s];if(!(r[Rn]||r[it]||e==="link"&&r.getAttribute("rel")==="stylesheet")&&r.namespaceURI!=="http://www.w3.org/2000/svg"){var m=r.getAttribute(t)||"";m=e+m;var b=a.get(m);b?b.push(r):a.set(m,[r])}}return a}function th(e,t,l){e=e.ownerDocument||e,e.head.insertBefore(l,t==="title"?e.querySelector("head > title"):null)}function Yg(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function lh(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function Gg(e,t,l,a){if(l.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var s=vn(a.href),r=t.querySelector(bs(s));if(r){t=r._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Wi.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=r,nt(r);return}r=t.ownerDocument||t,a=Im(a),(s=Zt.get(s))&&No(a,s),r=r.createElement("link"),nt(r);var m=r;m._p=new Promise(function(b,j){m.onload=b,m.onerror=j}),ut(r,"link",a),l.instance=r}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=Wi.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var zo=0;function $g(e,t){return e.stylesheets&&e.count===0&&Ii(e,e.stylesheets),0<e.count||0<e.imgCount?function(l){var a=setTimeout(function(){if(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend){var r=e.unsuspend;e.unsuspend=null,r()}},6e4+t);0<e.imgBytes&&zo===0&&(zo=62500*Sg());var s=setTimeout(function(){if(e.waitingForImages=!1,e.count===0&&(e.stylesheets&&Ii(e,e.stylesheets),e.unsuspend)){var r=e.unsuspend;e.unsuspend=null,r()}},(e.imgBytes>zo?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(a),clearTimeout(s)}}:null}function Wi(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ii(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Fi=null;function Ii(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Fi=new Map,t.forEach(Qg,e),Fi=null,Wi.call(e))}function Qg(e,t){if(!(t.state.loading&4)){var l=Fi.get(e);if(l)var a=l.get(null);else{l=new Map,Fi.set(e,l);for(var s=e.querySelectorAll("link[data-precedence],style[data-precedence]"),r=0;r<s.length;r++){var m=s[r];(m.nodeName==="LINK"||m.getAttribute("media")!=="not all")&&(l.set(m.dataset.precedence,m),a=m)}a&&l.set(null,a)}s=t.instance,m=s.getAttribute("data-precedence"),r=l.get(m)||a,r===a&&l.set(null,s),l.set(m,s),this.count++,a=Wi.bind(this),s.addEventListener("load",a),s.addEventListener("error",a),r?r.parentNode.insertBefore(s,r.nextSibling):(e=e.nodeType===9?e.head:e,e.insertBefore(s,e.firstChild)),t.state.loading|=4}}var ys={$$typeof:Z,Provider:null,Consumer:null,_currentValue:te,_currentValue2:te,_threadCount:0};function Xg(e,t,l,a,s,r,m,b,j){this.tag=1,this.containerInfo=e,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=bc(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=bc(0),this.hiddenUpdates=bc(null),this.identifierPrefix=a,this.onUncaughtError=s,this.onCaughtError=r,this.onRecoverableError=m,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=j,this.incompleteTransitions=new Map}function ah(e,t,l,a,s,r,m,b,j,C,R,B){return e=new Xg(e,t,l,m,j,C,R,B,b),t=1,r===!0&&(t|=24),r=Mt(3,null,null,t),e.current=r,r.stateNode=e,t=lr(),t.refCount++,e.pooledCache=t,t.refCount++,r.memoizedState={element:a,isDehydrated:l,cache:t},ir(r),e}function nh(e){return e?(e=Wa,e):Wa}function sh(e,t,l,a,s,r){s=nh(s),a.context===null?a.context=s:a.pendingContext=s,a=Ql(t),a.payload={element:l},r=r===void 0?null:r,r!==null&&(a.callback=r),l=Xl(e,a,t),l!==null&&(_t(l,e,t),In(l,e,t))}function ih(e,t){if(e=e.memoizedState,e!==null&&e.dehydrated!==null){var l=e.retryLane;e.retryLane=l!==0&&l<t?l:t}}function Eo(e,t){ih(e,t),(e=e.alternate)&&ih(e,t)}function ch(e){if(e.tag===13||e.tag===31){var t=ba(e,67108864);t!==null&&_t(t,e,67108864),Eo(e,67108864)}}function rh(e){if(e.tag===13||e.tag===31){var t=Ut();t=vc(t);var l=ba(e,t);l!==null&&_t(l,e,t),Eo(e,t)}}var Pi=!0;function Vg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=2,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function Zg(e,t,l,a){var s=k.T;k.T=null;var r=Y.p;try{Y.p=8,wo(e,t,l,a)}finally{Y.p=r,k.T=s}}function wo(e,t,l,a){if(Pi){var s=_o(a);if(s===null)fo(e,t,a,ec,l),uh(e,a);else if(Jg(s,e,t,l,a))a.stopPropagation();else if(uh(e,a),t&4&&-1<Kg.indexOf(e)){for(;s!==null;){var r=Ba(s);if(r!==null)switch(r.tag){case 3:if(r=r.stateNode,r.current.memoizedState.isDehydrated){var m=sl(r.pendingLanes);if(m!==0){var b=r;for(b.pendingLanes|=2,b.entangledLanes|=2;m;){var j=1<<31-xt(m);b.entanglements[1]|=j,m&=~j}ol(r),(we&6)===0&&(Ui=dt()+500,hs(0))}}break;case 31:case 13:b=ba(r,2),b!==null&&_t(b,r,2),Li(),Eo(r,2)}if(r=_o(a),r===null&&fo(e,t,a,ec,l),r===s)break;s=r}s!==null&&a.stopPropagation()}else fo(e,t,a,null,l)}}function _o(e){return e=Tc(e),To(e)}var ec=null;function To(e){if(ec=null,e=La(e),e!==null){var t=d(e);if(t===null)e=null;else{var l=t.tag;if(l===13){if(e=h(t),e!==null)return e;e=null}else if(l===31){if(e=p(t),e!==null)return e;e=null}else if(l===3){if(t.stateNode.current.memoizedState.isDehydrated)return t.tag===3?t.stateNode.containerInfo:null;e=null}else t!==e&&(e=null)}}return ec=e,null}function oh(e){switch(e){case"beforetoggle":case"cancel":case"click":case"close":case"contextmenu":case"copy":case"cut":case"auxclick":case"dblclick":case"dragend":case"dragstart":case"drop":case"focusin":case"focusout":case"input":case"invalid":case"keydown":case"keypress":case"keyup":case"mousedown":case"mouseup":case"paste":case"pause":case"play":case"pointercancel":case"pointerdown":case"pointerup":case"ratechange":case"reset":case"resize":case"seeked":case"submit":case"toggle":case"touchcancel":case"touchend":case"touchstart":case"volumechange":case"change":case"selectionchange":case"textInput":case"compositionstart":case"compositionend":case"compositionupdate":case"beforeblur":case"afterblur":case"beforeinput":case"blur":case"fullscreenchange":case"focus":case"hashchange":case"popstate":case"select":case"selectstart":return 2;case"drag":case"dragenter":case"dragexit":case"dragleave":case"dragover":case"mousemove":case"mouseout":case"mouseover":case"pointermove":case"pointerout":case"pointerover":case"scroll":case"touchmove":case"wheel":case"mouseenter":case"mouseleave":case"pointerenter":case"pointerleave":return 8;case"message":switch($s()){case Qs:return 2;case Cn:return 8;case Da:case Mn:return 32;case pl:return 268435456;default:return 32}default:return 32}}var Ao=!1,la=null,aa=null,na=null,js=new Map,Ns=new Map,sa=[],Kg="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset".split(" ");function uh(e,t){switch(e){case"focusin":case"focusout":la=null;break;case"dragenter":case"dragleave":aa=null;break;case"mouseover":case"mouseout":na=null;break;case"pointerover":case"pointerout":js.delete(t.pointerId);break;case"gotpointercapture":case"lostpointercapture":Ns.delete(t.pointerId)}}function Ss(e,t,l,a,s,r){return e===null||e.nativeEvent!==r?(e={blockedOn:t,domEventName:l,eventSystemFlags:a,nativeEvent:r,targetContainers:[s]},t!==null&&(t=Ba(t),t!==null&&ch(t)),e):(e.eventSystemFlags|=a,t=e.targetContainers,s!==null&&t.indexOf(s)===-1&&t.push(s),e)}function Jg(e,t,l,a,s){switch(t){case"focusin":return la=Ss(la,e,t,l,a,s),!0;case"dragenter":return aa=Ss(aa,e,t,l,a,s),!0;case"mouseover":return na=Ss(na,e,t,l,a,s),!0;case"pointerover":var r=s.pointerId;return js.set(r,Ss(js.get(r)||null,e,t,l,a,s)),!0;case"gotpointercapture":return r=s.pointerId,Ns.set(r,Ss(Ns.get(r)||null,e,t,l,a,s)),!0}return!1}function fh(e){var t=La(e.target);if(t!==null){var l=d(t);if(l!==null){if(t=l.tag,t===13){if(t=h(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===31){if(t=p(l),t!==null){e.blockedOn=t,zu(e.priority,function(){rh(l)});return}}else if(t===3&&l.stateNode.current.memoizedState.isDehydrated){e.blockedOn=l.tag===3?l.stateNode.containerInfo:null;return}}}e.blockedOn=null}function tc(e){if(e.blockedOn!==null)return!1;for(var t=e.targetContainers;0<t.length;){var l=_o(e.nativeEvent);if(l===null){l=e.nativeEvent;var a=new l.constructor(l.type,l);_c=a,l.target.dispatchEvent(a),_c=null}else return t=Ba(l),t!==null&&ch(t),e.blockedOn=l,!1;t.shift()}return!0}function dh(e,t,l){tc(e)&&l.delete(t)}function Wg(){Ao=!1,la!==null&&tc(la)&&(la=null),aa!==null&&tc(aa)&&(aa=null),na!==null&&tc(na)&&(na=null),js.forEach(dh),Ns.forEach(dh)}function lc(e,t){e.blockedOn===t&&(e.blockedOn=null,Ao||(Ao=!0,i.unstable_scheduleCallback(i.unstable_NormalPriority,Wg)))}var ac=null;function mh(e){ac!==e&&(ac=e,i.unstable_scheduleCallback(i.unstable_NormalPriority,function(){ac===e&&(ac=null);for(var t=0;t<e.length;t+=3){var l=e[t],a=e[t+1],s=e[t+2];if(typeof a!="function"){if(To(a||l)===null)continue;break}var r=Ba(l);r!==null&&(e.splice(t,3),t-=3,wr(r,{pending:!0,data:s,method:l.method,action:a},a,s))}}))}function jn(e){function t(j){return lc(j,e)}la!==null&&lc(la,e),aa!==null&&lc(aa,e),na!==null&&lc(na,e),js.forEach(t),Ns.forEach(t);for(var l=0;l<sa.length;l++){var a=sa[l];a.blockedOn===e&&(a.blockedOn=null)}for(;0<sa.length&&(l=sa[0],l.blockedOn===null);)fh(l),l.blockedOn===null&&sa.shift();if(l=(e.ownerDocument||e).$$reactFormReplay,l!=null)for(a=0;a<l.length;a+=3){var s=l[a],r=l[a+1],m=s[jt]||null;if(typeof r=="function")m||mh(l);else if(m){var b=null;if(r&&r.hasAttribute("formAction")){if(s=r,m=r[jt]||null)b=m.formAction;else if(To(s)!==null)continue}else b=m.action;typeof b=="function"?l[a+1]=b:(l.splice(a,3),a-=3),mh(l)}}}function hh(){function e(r){r.canIntercept&&r.info==="react-transition"&&r.intercept({handler:function(){return new Promise(function(m){return s=m})},focusReset:"manual",scroll:"manual"})}function t(){s!==null&&(s(),s=null),a||setTimeout(l,20)}function l(){if(!a&&!navigation.transition){var r=navigation.currentEntry;r&&r.url!=null&&navigation.navigate(r.url,{state:r.getState(),info:"react-transition",history:"replace"})}}if(typeof navigation=="object"){var a=!1,s=null;return navigation.addEventListener("navigate",e),navigation.addEventListener("navigatesuccess",t),navigation.addEventListener("navigateerror",t),setTimeout(l,100),function(){a=!0,navigation.removeEventListener("navigate",e),navigation.removeEventListener("navigatesuccess",t),navigation.removeEventListener("navigateerror",t),s!==null&&(s(),s=null)}}}function Co(e){this._internalRoot=e}nc.prototype.render=Co.prototype.render=function(e){var t=this._internalRoot;if(t===null)throw Error(u(409));var l=t.current,a=Ut();sh(l,a,e,t,null,null)},nc.prototype.unmount=Co.prototype.unmount=function(){var e=this._internalRoot;if(e!==null){this._internalRoot=null;var t=e.containerInfo;sh(e.current,2,null,e,null,null),Li(),t[Ha]=null}};function nc(e){this._internalRoot=e}nc.prototype.unstable_scheduleHydration=function(e){if(e){var t=Su();e={blockedOn:null,target:e,priority:t};for(var l=0;l<sa.length&&t!==0&&t<sa[l].priority;l++);sa.splice(l,0,e),l===0&&fh(e)}};var ph=c.version;if(ph!=="19.2.8")throw Error(u(527,ph,"19.2.8"));Y.findDOMNode=function(e){var t=e._reactInternals;if(t===void 0)throw typeof e.render=="function"?Error(u(188)):(e=Object.keys(e).join(","),Error(u(268,e)));return e=x(t),e=e!==null?v(e):null,e=e===null?null:e.stateNode,e};var Fg={bundleType:0,version:"19.2.8",rendererPackageName:"react-dom",currentDispatcherRef:k,reconcilerVersion:"19.2.8"};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"){var sc=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!sc.isDisabled&&sc.supportsFiber)try{al=sc.inject(Fg),pt=sc}catch{}}return Es.createRoot=function(e,t){if(!f(e))throw Error(u(299));var l=!1,a="",s=Nd,r=Sd,m=zd;return t!=null&&(t.unstable_strictMode===!0&&(l=!0),t.identifierPrefix!==void 0&&(a=t.identifierPrefix),t.onUncaughtError!==void 0&&(s=t.onUncaughtError),t.onCaughtError!==void 0&&(r=t.onCaughtError),t.onRecoverableError!==void 0&&(m=t.onRecoverableError)),t=ah(e,1,!1,null,null,l,a,null,s,r,m,hh),e[Ha]=t.current,uo(e),new Co(t)},Es.hydrateRoot=function(e,t,l){if(!f(e))throw Error(u(299));var a=!1,s="",r=Nd,m=Sd,b=zd,j=null;return l!=null&&(l.unstable_strictMode===!0&&(a=!0),l.identifierPrefix!==void 0&&(s=l.identifierPrefix),l.onUncaughtError!==void 0&&(r=l.onUncaughtError),l.onCaughtError!==void 0&&(m=l.onCaughtError),l.onRecoverableError!==void 0&&(b=l.onRecoverableError),l.formState!==void 0&&(j=l.formState)),t=ah(e,1,!0,t,l??null,a,s,j,r,m,b,hh),t.context=nh(null),l=t.current,a=Ut(),a=vc(a),s=Ql(a),s.callback=null,Xl(l,s,a),l=a,t.current.lanes=l,Dn(t,l),ol(t),e[Ha]=t.current,uo(e),new nc(t)},Es.version="19.2.8",Es}var wh;function ub(){if(wh)return Oo.exports;wh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Oo.exports=ob(),Oo.exports}var Qo=ub();/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fb=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),db=i=>i.replace(/^([A-Z])|[\s-_]+(\w)/g,(c,o,u)=>u?u.toUpperCase():o.toLowerCase()),_h=i=>{const c=db(i);return c.charAt(0).toUpperCase()+c.slice(1)},fp=(...i)=>i.filter((c,o,u)=>!!c&&c.trim()!==""&&u.indexOf(c)===o).join(" ").trim(),mb=i=>{for(const c in i)if(c.startsWith("aria-")||c==="role"||c==="title")return!0};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var hb={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pb=q.forwardRef(({color:i="currentColor",size:c=24,strokeWidth:o=2,absoluteStrokeWidth:u,className:f="",children:d,iconNode:h,...p},g)=>q.createElement("svg",{ref:g,...hb,width:c,height:c,stroke:i,strokeWidth:u?Number(o)*24/Number(c):o,className:fp("lucide",f),...!d&&!mb(p)&&{"aria-hidden":"true"},...p},[...h.map(([x,v])=>q.createElement(x,v)),...Array.isArray(d)?d:[d]]));/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const He=(i,c)=>{const o=q.forwardRef(({className:u,...f},d)=>q.createElement(pb,{ref:d,iconNode:c,className:fp(`lucide-${fb(_h(i))}`,`lucide-${i}`,u),...f}));return o.displayName=_h(i),o};/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const xb=[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]],Sn=He("arrow-up-right",xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gb=[["path",{d:"M2 20v-8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v8",key:"1k78r4"}],["path",{d:"M4 10V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4",key:"fb3tl2"}],["path",{d:"M12 4v6",key:"1dcgq2"}],["path",{d:"M2 18h20",key:"ajqnye"}]],bb=He("bed-double",gb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"m9 16 2 2 4-4",key:"19s6y9"}]],yb=He("calendar-check",vb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jb=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}],["path",{d:"M8 14h.01",key:"6423bh"}],["path",{d:"M12 14h.01",key:"1etili"}],["path",{d:"M16 14h.01",key:"1gbofw"}],["path",{d:"M8 18h.01",key:"lrp35t"}],["path",{d:"M12 18h.01",key:"mhygvu"}],["path",{d:"M16 18h.01",key:"kzsmim"}]],Nb=He("calendar-days",jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Sb=[["path",{d:"M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2",key:"5owen"}],["circle",{cx:"7",cy:"17",r:"2",key:"u2ysq9"}],["path",{d:"M9 17h6",key:"r8uit2"}],["circle",{cx:"17",cy:"17",r:"2",key:"axvx0g"}]],zb=He("car",Sb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Eb=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],dp=He("check",Eb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const wb=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],fc=He("chevron-down",wb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _b=[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]],oa=He("chevron-left",_b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Tb=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],ua=He("chevron-right",Tb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ab=[["path",{d:"M12 6v6l4 2",key:"mmk7yg"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],Cb=He("clock",Ab);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mb=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],kb=He("copy",Mb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ob=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],Db=He("external-link",Ob);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Rb=[["rect",{width:"20",height:"20",x:"2",y:"2",rx:"5",ry:"5",key:"2e1cvw"}],["path",{d:"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z",key:"9exkf1"}],["line",{x1:"17.5",x2:"17.51",y1:"6.5",y2:"6.5",key:"r4j83e"}]],Ub=He("instagram",Rb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hb=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],mp=He("mail",Hb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Lb=[["path",{d:"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",key:"1r0f0z"}],["circle",{cx:"12",cy:"10",r:"3",key:"ilqhr7"}]],Ls=He("map-pin",Lb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Bb=[["path",{d:"M4 5h16",key:"1tepv9"}],["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 19h16",key:"1djgab"}]],hp=He("menu",Bb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qb=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],Io=He("message-circle",qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yb=[["path",{d:"M5 12h14",key:"1ays0h"}]],Gb=He("minus",Yb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $b=[["polygon",{points:"3 11 22 2 13 21 11 13 3 11",key:"1ltx0t"}]],Ho=He("navigation",$b);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qb=[["path",{d:"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",key:"9njp5v"}]],At=He("phone",Qb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Xb=[["path",{d:"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z",key:"10ikf1"}]],Vb=He("play",Xb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zb=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],Kb=He("plus",Zb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jb=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],Wb=He("rotate-ccw",Jb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fb=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],Ib=He("shield-check",Fb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pb=[["path",{d:"M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2",key:"cjf0a3"}],["path",{d:"M7 2v20",key:"1473qp"}],["path",{d:"M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7",key:"j28e5"}]],e0=He("utensils",Pb);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const t0=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],pp=He("x",t0);/** + * @license lucide-react v0.546.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const l0=[["path",{d:"M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",key:"1q2vi4"}],["path",{d:"m10 15 5-3-5-3z",key:"1jp15x"}]],a0=He("youtube",l0),xp=q.createContext(null);function n0({payload:i,children:c}){return n.jsx(xp.Provider,{value:i,children:c})}function le(){const i=q.useContext(xp);if(!i)throw new Error("SiteProvider 밖에서 useSite() 를 불렀습니다. payload 없이는 렌더할 수 없습니다.");return i}function s0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.6",children:[n.jsx("rect",{x:"2",y:"5",width:"20",height:"14",rx:"2"}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"8.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M8.5 9.4v5.2M5.9 12h5.2"})]}),n.jsxs("g",{className:"w4d-reel",children:[n.jsx("circle",{cx:"15.5",cy:"12",r:"2.6"}),n.jsx("path",{d:"M15.5 9.4v5.2M12.9 12h5.2"})]}),n.jsx("path",{d:"M6 19l1.5-2.4h9L18 19"})]})}function i0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M8 5v14l11-7z"})})}function c0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",children:n.jsx("path",{d:"M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"})})}function r0(){return n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:[n.jsx("path",{d:"M4 7h11M4 12h11M4 17h7"}),n.jsx("path",{d:"M19 17.5V9l3 1.2"}),n.jsx("circle",{cx:"17.4",cy:"17.6",r:"1.7",fill:"currentColor",stroke:"none"})]})}function o0(){return n.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",children:n.jsx("path",{d:"M6 6l12 12M18 6L6 18"})})}function Th(i){if(typeof i!="number"||!Number.isFinite(i))return"";const c=Math.floor(i/60),o=Math.floor(i%60);return`${c}:${o<10?"0":""}${o}`}function u0(){const i=le(),c=(i.songs??[]).filter($=>$.audioUrl),[o,u]=q.useState(0),[f,d]=q.useState(!1),[h,p]=q.useState(!1),[g,x]=q.useState({now:0,total:0}),[v,y]=q.useState({}),z=q.useRef(null),w=q.useRef(null),N=q.useRef(null),T=c.length;q.useEffect(()=>{T>1&&u(Math.floor(Math.random()*T))},[T]);const D=q.useCallback(()=>{var ee;const $=document.querySelector("header"),K=$==null?void 0:$.getBoundingClientRect(),G={top:`${K?K.bottom+6:66}px`};if(window.innerWidth<=640)G.left="8px",G.right="8px";else{G.left="auto";const se=(ee=w.current)==null?void 0:ee.getBoundingClientRect();G.right=`${Math.max(12,window.innerWidth-((se==null?void 0:se.right)??0))}px`}y(G)},[]);if(q.useEffect(()=>{if(!h)return;D();const $=()=>D(),K=G=>{var se,Q;const ee=G.target;(se=N.current)!=null&&se.contains(ee)||(Q=w.current)!=null&&Q.contains(ee)||p(!1)};return window.addEventListener("resize",$),window.addEventListener("scroll",$,{passive:!0}),document.addEventListener("click",K),()=>{window.removeEventListener("resize",$),window.removeEventListener("scroll",$),document.removeEventListener("click",K)}},[h,D]),q.useEffect(()=>()=>{var $;return($=z.current)==null?void 0:$.pause()},[]),c.length===0)return null;const U=c[o]??c[0],X=($=o)=>{const K=z.current,G=c[$];!K||!G||(K.getAttribute("src")!==G.audioUrl&&(K.src=G.audioUrl),K.play().then(()=>d(!0),()=>d(!1)))},Z=()=>{var $;($=z.current)==null||$.pause(),d(!1)},P=$=>{const K=($%c.length+c.length)%c.length;u(K),x({now:0,total:0}),X(K)};return n.jsxs(n.Fragment,{children:[n.jsxs("span",{id:"w4d-mini",ref:w,"data-playing":f?"1":"0",children:[n.jsx("span",{id:"w4d-tape","aria-hidden":!0,title:`${i.place.name}이(가) 만든 노래`,children:n.jsx(s0,{})}),n.jsx("span",{id:"w4d-now","aria-live":"polite",children:U.title}),n.jsx("button",{type:"button",id:"w4d-play","data-playing":f?"1":"0",title:`${f?"멈춤":"재생"} · ${U.title}`,onClick:()=>f?Z():X(),children:f?n.jsx(c0,{}):n.jsx(i0,{})}),n.jsx("button",{type:"button",id:"w4d-toggle",title:"노래 목록","aria-expanded":h,onClick:()=>p($=>!$),children:n.jsx(r0,{})})]}),n.jsx("audio",{ref:z,src:c[0].audioUrl,preload:"none",onTimeUpdate:$=>x({now:$.currentTarget.currentTime,total:$.currentTarget.duration}),onDurationChange:$=>x(K=>({...K,total:$.currentTarget.duration})),onEnded:()=>P(o+1),onError:()=>d(!1)}),n.jsxs("div",{id:"w4d-panel",ref:N,hidden:!h,style:v,children:[n.jsxs("div",{className:"w4d-head",children:[n.jsxs("span",{className:"w4d-head-t",children:[i.place.name,"이(가) 만든 노래"]}),n.jsxs("span",{className:"w4d-head-n",children:[c.length,"곡"]}),n.jsx("button",{type:"button",id:"w4d-close",title:"닫기","aria-label":"닫기",onClick:()=>p(!1),children:n.jsx(o0,{})})]}),n.jsx("ol",{children:c.map(($,K)=>n.jsx("li",{children:n.jsxs("button",{type:"button",className:"w4d-item","aria-current":K===o,"data-playing":K===o&&f?"1":"0",onClick:()=>P(K),children:[n.jsxs("span",{className:"w4d-mark",children:[n.jsx("span",{className:"w4d-disc-sm"}),n.jsxs("span",{className:"w4d-eq","aria-hidden":!0,children:[n.jsx("i",{}),n.jsx("i",{}),n.jsx("i",{})]})]}),n.jsxs("span",{className:"w4d-info",children:[n.jsx("span",{className:"w4d-t",children:$.title}),n.jsx("span",{className:"w4d-sub",children:i.place.name})]}),n.jsx("span",{className:"w4d-time",children:K===o&&(f||g.now>0)?`${Th(g.now)}${g.total?` / ${Th(g.total)}`:""}`:""})]})},$.songId))}),n.jsx("div",{id:"w4d-lyrics-box",hidden:!U.lyrics,children:(U.lyrics??"").replace(/^\[.*\]$/gm,"").replace(/\n{3,}/g,` + +`).trim()})]})]})}const Ma={LODGING:1,CAFE:2,RESTAURANT:3,CLINIC:4},N1={OWNER:1,CRAWL:3},Ah={UNVERIFIED:1,VERIFIED:3,CORRECTED:4},f0=[Ah.VERIFIED,Ah.CORRECTED];function gp(i){return f0.includes(i)}const bt={YANOLJA:1,GOODCHOICE:2,NAVER_PLACE:3,INSTAGRAM:4,OFFICIAL_SITE:5,BLOG:6,NAVER_BOOKING:7,ETC:99},S1={PUBLISHED:3};function Bs(i){return i.filter(c=>gp(c.status)&&c.value!=null&&c.value!=="")}function bp(i){const c=new Map;for(const o of Bs(i))c.set(o.key,o);return c}function ca(i,c){const o=bp(i).get(c);return(o==null?void 0:o.value)??void 0}function Tt(i,c){const o=bp(i).get(c);if(!(o!=null&&o.value))return;const u=o.type==="number"?d0(o.value):o.value;return o.unit?`${u}${o.unit}`:u}function d0(i){const c=Number(i.replace(/,/g,""));return Number.isFinite(c)?c.toLocaleString("ko-KR"):i}function Po(i){return i.type!=="bool"?i.label:i.label.replace(/\s*(가능|여부)$/,"")||i.label}function m0(i){return i.filter(c=>gp(c.status)&&c.question&&c.answer).sort((c,o)=>c.sortOrder-o.sortOrder)}function eu(i){return i.map(c=>({...c,facts:Bs(c.facts)})).sort((c,o)=>c.sortOrder-o.sortOrder)}const h0={songs:"title",daily:"title",course:"name",schedule:"name",people:"name",chronicle:"title",literature:"workTitle",postcard:"line",quiz:"question",planner:"name",itinerary:"name",video:"url",event:"title"};function p0(i){const c=(i??"").trim();if(!c)return;const o=/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec(c);return o==null?void 0:o[1]}function x0(i){return/youtube\.com\/shorts\//.test((i??"").trim())}function g0(i){return`https://i.ytimg.com/vi/${i}/hqdefault.jpg`}function b0(i){return`https://www.youtube-nocookie.com/embed/${i}?autoplay=1&rel=0&modestbranding=1`}const ic={items:[],unverified:0,sourced:0};function v0(i,c){var x,v;const o=y=>{const z=i.slice(0,Math.max(0,y)),w=z.split(` +`).length,N=y-z.lastIndexOf(` +`);return`${w}번째 줄 ${N}번째 글자`},u=(x=/Unexpected token '(.)'/.exec(c))==null?void 0:x[1],f=u==="}"||u==="]"?"닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.":"그 앞의 쉼표·따옴표·괄호를 확인해 주세요.",d=/line (\d+) column (\d+)/.exec(c);if(d)return`${d[1]}번째 줄 ${d[2]}번째 글자에서 JSON 이 끊깁니다. ${f}`;const h=/position (\d+)/.exec(c);if(h)return`${o(Number(h[1]))}에서 JSON 이 끊깁니다. ${f}`;const p=(v=/\.\.\."([\s\S]*?)" is not valid JSON/.exec(c))==null?void 0:v[1],g=p?i.indexOf(p):-1;return g>=0?`${o(g+p.length)} 부근에서 JSON 이 끊깁니다. ${f}`:"JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요."}function y0(i,c){const o=h0[i],u=(c??"").trim();if(!o||!u)return ic;let f;try{f=JSON.parse(u)}catch(y){return{...ic,error:v0(u,y instanceof Error?y.message:"")}}if(typeof f!="object"||f===null||Array.isArray(f))return{...ic,error:"바깥이 { } 로 감싸인 JSON 이어야 합니다."};const d=f,h=typeof d.kind=="string"?d.kind:void 0,p=d.items;if(!Array.isArray(p))return{...ic,error:"items 배열이 없습니다. 프롬프트로 다시 만들어 주세요."};const g=p.filter(y=>typeof y=="object"&&y!==null&&!Array.isArray(y)&&typeof y[o]=="string"&&y[o].trim().length>0);let x=0,v=0;for(const y of g){const z=y;z.verified!=="확인"&&(x+=1),z.source&&typeof z.source=="object"&&(v+=1)}return{items:g,title:typeof d.title=="string"?d.title:void 0,subtitle:typeof d.subtitle=="string"?d.subtitle:void 0,linkUrl:typeof d.linkUrl=="string"&&/^https?:\/\//.test(d.linkUrl)?d.linkUrl:void 0,linkLabel:typeof d.linkLabel=="string"?d.linkLabel:void 0,kindMismatch:h&&h!==i?h:void 0,unverified:x,sourced:v}}const j0=1260,N0=60,S0="10:00",Ch=["봄","여름","가을","겨울"];function Mh(i){const c=/^(\d{1,2}):(\d{2})$/.exec(i.trim());if(!c)return;const o=Number(c[1]),u=Number(c[2]);if(!(o>23||u>59))return o*60+u}function cc(i){const c=(i%1440+1440)%1440;return`${String(Math.floor(c/60)).padStart(2,"0")}:${String(c%60).padStart(2,"0")}`}function z0(i){const c=Mh(i.startTime??"")??Mh(S0)??600,o=[];let u=c,f=0;for(const d of i.stops??[]){const h=Math.max(0,d.moveMinutes??0),p=Math.max(1,d.minutes??N0),g=u+h;if(g+p>j0){f+=1;continue}o.push({stop:d,time:cc(g),until:cc(g+p),move:h}),u=g+p}return{stops:o,from:cc(c),to:cc(u),totalMinutes:u-c,dropped:f}}function E0(i=new Date){const c=i.getMonth()+1,o=Math.floor((c-3+12)%12/3),u=Ch[o];return c%3===0&&i.getDate()<=15?[Ch[(o+3)%4],u]:[u]}function kh(i){const c=parseInt(i.replace("#",""),16);return[c>>16&255,c>>8&255,c&255]}function Oh(i,c,o){if(!/^#[0-9a-fA-F]{6}$/.test(i)||!/^#[0-9a-fA-F]{6}$/.test(c))return i;const[u,f,d]=kh(i),[h,p,g]=kh(c),x=(y,z)=>Math.round(y+(z-y)*o),v=y=>y.toString(16).padStart(2,"0");return`#${v(x(u,h))}${v(x(f,p))}${v(x(d,g))}`}function w0(i){return{surface:Oh(i.bg,i.text,.06),surfaceAlt:i.card,inverse:"#1c1917",border:Oh(i.bg,i.text,.2)}}const vp={[Ma.LODGING]:{type:"HotelRoom",path:"rooms",label:"객실"},[Ma.CAFE]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.RESTAURANT]:{type:"MenuItem",path:"menu",label:"메뉴"},[Ma.CLINIC]:{type:"Product",path:"programs",label:"시술"}};function _0(i){return i.links.filter(c=>c.confirmed&&/^https?:\/\//i.test(c.url??""))}function yp(i){var c;for(const[o,u]of[["weekday_price","주중 1박"],["price","1박"],["weekend_price","주말 1박"],["peak_price","성수기 1박"]]){const f=Number((c=ca(i.facts,o))==null?void 0:c.replace(/[^0-9]/g,""));if(Number.isFinite(f)&&f>0)return{price:f,label:u}}}const Dh=[bt.NAVER_BOOKING,bt.YANOLJA,bt.GOODCHOICE,bt.NAVER_PLACE];function dc(i){return Bs(i.facts).filter(c=>c.scope==="place").map(c=>({label:Po(c),value:tu(c,i.facts)})).filter(c=>c.value!=="")}function tu(i,c){const o=Tt(c,i.key);return o?i.type!=="bool"?o:/여부$/.test(i.label)?o==="true"?"있음":o==="false"?"없음":o:o==="true"?"가능":o==="false"?"불가":o:""}const T0=new Set(["weekday_price","weekend_price","peak_price","price_range"]);function fl(i){const c=vp[i.place.category],o=new Map(i.media.map(u=>[u.mediaId,u]));return eu(i.units).map(u=>{const f=Tt(u.facts,"room_intro")??Tt(u.facts,"description"),d=i.place.category===Ma.LODGING?["standard_capacity","max_capacity","bed_type","room_size"]:["price","volume","origin"];return{unitId:u.unitId,slug:u.slug,name:u.name,intro:f,chips:d.map(h=>{var x;const p=Tt(u.facts,h),g=((x=u.facts.find(v=>v.key===h))==null?void 0:x.label)??h;return p?{label:g,value:p}:null}).filter(h=>h!==null),rows:u.facts.filter(h=>!T0.has(h.key)).map(h=>({label:Po(h),value:tu(h,u.facts)})).filter(h=>h.value!==""),images:u.mediaIds.map(h=>o.get(h)).filter(h=>{var p;return!!((p=h==null?void 0:h.alt)!=null&&p.trim())}),priceText:A0(u),prices:M0(u),href:`/${c.path}/${u.slug}`}})}function A0(i){var d,h;const c=Tt(i.facts,"price_range");if(c)return c;const o=Number((d=Tt(i.facts,"weekday_price"))==null?void 0:d.replace(/[^0-9]/g,"")),u=Number((h=Tt(i.facts,"price"))==null?void 0:h.replace(/[^0-9]/g,"")),f=Number.isFinite(o)&&o>0?o:u;if(!(!Number.isFinite(f)||f<=0))return`${f.toLocaleString("ko-KR")}원부터`}const C0=[["weekday_price","주중"],["weekend_price","주말"],["peak_price","성수기"]];function M0(i){return Tt(i.facts,"price_range")?[]:Tt(i.facts,"weekday_price")?C0.map(([c,o])=>({label:o,value:Tt(i.facts,c)??"문의"})):[]}function k0(i){const c=i??"";return/눈|설/.test(c)?"눈":/비|우|소나기/.test(c)?"비":/흐림|구름|안개|박무/.test(c)?"흐림":"맑음"}function O0(i){return i>=30?"혹서":i>=25?"더움":i>=20?"선선":i>=10?"쌀쌀":"추움"}function lu(i){return i.media.filter(c=>{var o;return((o=c.alt)==null?void 0:o.trim())&&!c.unitId})}function D0(i){return m0(i.faqs)}function R0(i){return i.theme.sections.filter(c=>c.enabled)}function Kt(i,c){var o;return((o=i.theme.sections.find(u=>u.id===c))==null?void 0:o.enabled)??!1}function U0(i,c){var u;const o=(u=i.theme.sections.find(f=>f.id===c))==null?void 0:u.body;return(o==null?void 0:o.split(/\n\s*\n/).map(f=>f.trim()).filter(Boolean))??[]}function dl(i,c){var d;const o=i.theme.sections.find(h=>h.id===c),u=y0(c,o==null?void 0:o.data),f=(d=i.local.story)==null?void 0:d[c];return!Array.isArray(f)||f.length===0?u:{...u,items:[...u.items,...f]}}function ft(i){return vp[i.place.category]}function Ye(i,c,o){var f,d;const u=(d=(f=i.theme.sections.find(h=>h.id===c))==null?void 0:f.name)==null?void 0:d.trim();return u||o}function zn(i,c){const o=new Map(Bs(i.facts).filter(u=>u.scope==="place").map(u=>[u.key,u]));return c.map(u=>o.get(u)).filter(u=>u!==void 0).map(u=>({label:Po(u),value:tu(u,i.facts)})).filter(u=>u.value!=="")}function H0(i,c){return zn(i,[c])[0]}const L0=["reservation_required","reservation_channel"];function B0(i){return zn(i,L0)}const q0=["seat_count","terrace","room_available","group_seat_max","power_outlet","study_allowed","wheelchair_accessible"];function Y0(i){return zn(i,q0)}const G0=["operating_hours","session_times","closed_days","age_limit","guide_language"];function $0(i){return zn(i,G0)}const Q0=["check_in_time","check_out_time","operating_hours","closed_days","parking","reservation_required"];function Oa(i){return zn(i,Q0).slice(0,4)}function En(i){const c=fl(i).map(u=>u.priceText).filter(u=>!!u);if(c.length===0)return;const o=u=>Number(u.replace(/[^0-9]/g,""))||Number.MAX_SAFE_INTEGER;return c.reduce((u,f)=>o(f)<o(u)?f:u)}const jp={[bt.NAVER_BOOKING]:"네이버 예약",[bt.YANOLJA]:"야놀자",[bt.GOODCHOICE]:"여기어때",[bt.NAVER_PLACE]:"네이버 플레이스",[bt.INSTAGRAM]:"인스타그램",[bt.OFFICIAL_SITE]:"공식 사이트",[bt.BLOG]:"블로그",[bt.ETC]:"채널"};function ml(i){return i.title??jp[i.channel]??"채널"}function ks(i){return jp[i.channel]??ml(i)}const X0=[bt.NAVER_PLACE,bt.INSTAGRAM];function Np(i,c){return i.links.filter(o=>o.confirmed&&c.includes(o.channel))}function Ze(i){const c=new Map(Dh.map((o,u)=>[o,u]));return Np(i,Dh).filter(o=>!V0(o.url)).sort((o,u)=>(c.get(o.channel)??99)-(c.get(u.channel)??99))}function V0(i){return/\/p\/search\/|[?&]query=/.test(i)}const Z0=["check_in_time","check_out_time","cancel_policy","extra_person_fee","reception_hours","cooking_allowed","pet_allowed","smoking"];function K0(i){return eu(i.units).map(c=>{const o=Tt(c.facts,"standard_capacity"),u=Tt(c.facts,"max_capacity"),f=yp(c);return{unitId:c.unitId,name:c.name,capacityText:[o&&`기준 ${o}`,u&&`최대 ${u}`].filter(Boolean).join(" · ")||void 0,rateRows:["weekday_price","weekend_price","peak_price"].map(d=>{var g;const h=Tt(c.facts,d),p=((g=c.facts.find(x=>x.key===d))==null?void 0:g.label)??d;return h?{label:p,value:h}:null}).filter(d=>d!==null),baseRateText:f?`${f.label} ${f.price.toLocaleString("ko-KR")}원`:void 0,href:"#units"}})}function J0(i){if(i.place.category!==Ma.LODGING)return null;const c={offers:K0(i).filter(u=>u.rateRows.length>0||u.capacityText!==void 0),notices:zn(i,Z0),links:Ze(i),contacts:wn(i).filter(u=>!Ze(i).some(f=>f.url===u.url)),phone:i.place.phone};return c.offers.length===0&&c.notices.length===0&&c.links.length===0&&c.contacts.length===0&&!c.phone?null:c}function W0(i,c){return i.theme.sections.some(o=>o.id===c)}function wn(i){return Np(i,X0)}function ht(i,c){const o=ks(i),u=o.endsWith("예약")?o:`${o} 예약`;return c?`${c} ${u}`:u}function Sp(i){return i.channel===bt.NAVER_BOOKING?"네이버 예약으로 바로 예약하기":`${ml(i)}에서 예약`}function F0(){var u;const i=le(),c=ft(i),o=[{label:"소개",href:"#about",show:Kt(i,"intro")},{label:c.label,href:"#units",show:i.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"축제",href:"#festival",show:(((u=i.local.festivals)==null?void 0:u.length)??0)>0},{label:"주변 정보",href:"#guide",show:Kt(i,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:i.faqs.length>0}].filter(f=>f.show);return n.jsx("header",{className:"border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 88%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsxs("a",{href:"#top",className:"flex min-w-0 items-center gap-2.5",children:[n.jsx("span",{className:"serif flex size-8 shrink-0 items-center justify-center rounded-md text-[length:var(--fs-sm)]",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",fontWeight:"var(--tpl-heading-weight, 700)"},"aria-hidden":!0,children:i.place.name.slice(0,1)}),n.jsxs("span",{className:"flex min-w-0 flex-col",children:[n.jsx("span",{className:"serif truncate text-[length:var(--fs-lead)] leading-tight",style:{fontWeight:"var(--tpl-heading-weight, 700)"},children:i.place.name}),i.place.englishName&&n.jsx("span",{className:"text-muted truncate text-[10px] uppercase leading-tight tracking-[0.14em]",children:i.place.englishName})]})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-7 lg:flex",children:o.map(f=>n.jsx("a",{href:f.href,className:"text-muted py-1 text-[length:var(--fs-sm)] font-medium transition-colors hover:text-current",children:f.label},f.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1.5",children:[n.jsx(u0,{}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap inline-flex items-center gap-1.5 rounded-lg px-3 text-[length:var(--fs-xs)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{className:"hidden sm:inline",children:i.place.phone}),n.jsx("span",{className:"sr-only sm:hidden",children:"전화"})]}),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-lg marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line tpl-border absolute right-0 top-[calc(100%+0.5rem)] z-50 w-52 overflow-hidden rounded-xl border shadow-lg",style:{backgroundColor:"var(--tpl-surface, #fff)"},children:n.jsx("ul",{className:"divide-line divide-y",children:o.map(f=>n.jsx("li",{children:n.jsx("a",{href:f.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-medium",children:f.label})},f.label))})})]})]})]})})}function mc(i){return i.media.find(c=>{var o;return c.isPrimary&&((o=c.alt)==null?void 0:o.trim())})??i.media.find(c=>{var o;return(o=c.alt)==null?void 0:o.trim()})}function I0(i){return P0(i).join(" ")}function P0(i){const{place:c,facts:o}=i,u=[],f=c.addressLocality??c.addressRegion??c.roadAddress??c.address;u.push(f?`${f}에 있는 ${c.name}입니다.`:`${c.name}입니다.`);const d=ca(o,"check_in_time"),h=ca(o,"check_out_time");d&&h&&u.push(`체크인 ${d}, 체크아웃 ${h}.`);const p=ca(o,"business_hours")??ca(o,"open_hours");p&&u.push(`영업시간 ${p}.`);const g=ca(o,"pet_allowed");if(g==="false"&&u.push("반려동물 동반 불가."),g==="true"&&u.push("반려동물 동반 가능."),ca(o,"parking")==="true"){const v=ca(o,"parking_capacity");u.push(v?`주차 ${v}대 가능.`:"주차 가능.")}return c.phone&&u.push(`문의 ${c.phone}.`),u}const ev=6e3,tv=5;function lv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=c.addressLocality??c.addressRegion,p=[...i.media].filter(N=>{var T;return(T=N.alt)==null?void 0:T.trim()}).sort((N,T)=>Number(T.isPrimary)-Number(N.isPrimary)).slice(0,tv),[g,x]=q.useState(0),[v,y]=q.useState(!1),z=q.useRef(null),w=q.useCallback(N=>x(T=>(T+N+p.length)%p.length),[p.length]);return q.useEffect(()=>{if(v||p.length<2)return;const N=setInterval(()=>w(1),ev);return()=>clearInterval(N)},[v,w,p.length]),n.jsxs("section",{id:"top",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"clamp(24rem, 76vh, 42rem)"},onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),onFocusCapture:()=>y(!0),onBlurCapture:()=>y(!1),onTouchStart:N=>{z.current=N.touches[0].clientX},onTouchEnd:N=>{const T=z.current;if(z.current=null,T===null)return;const D=N.changedTouches[0].clientX-T;Math.abs(D)>40&&w(D<0?1:-1)},children:[p.map((N,T)=>n.jsx("img",{src:N.url,alt:N.alt,fetchPriority:T===0?"high":"low",loading:T===0?void 0:"lazy",decoding:"async",className:"absolute inset-0 size-full object-cover object-center transition-opacity duration-700",style:{opacity:T===g?1:0}},N.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 45%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 60%, transparent) 100%)"}}),n.jsxs("div",{className:"pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-6 text-center",children:[h&&n.jsx("p",{className:"label opacity-85",children:h}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),(o.tagline??o.heroSubline)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90",children:o.tagline??o.heroSubline})]}),p.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Rh,{dir:"prev",onClick:()=>w(-1)}),n.jsx(Rh,{dir:"next",onClick:()=>w(1)}),n.jsx("ul",{className:"absolute inset-x-0 bottom-5 flex items-center justify-center gap-2",children:p.map((N,T)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>x(T),"aria-label":`${T+1}번째 사진`,"aria-current":T===g,className:`h-1.5 rounded-full bg-current transition-all ${T===g?"w-7 opacity-90":"w-1.5 opacity-45 hover:opacity-70"}`})},N.mediaId))})]})]}),(f||u.length>0||d.length>0||c.phone)&&n.jsx("div",{className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsxs("div",{className:"shell flex flex-col items-center gap-4 py-4 lg:flex-row lg:justify-between lg:gap-8",children:[n.jsxs("dl",{className:"divide-line flex flex-wrap justify-center gap-y-2 sm:divide-x",children:[f&&n.jsxs("div",{className:"px-5 text-center sm:first:pl-0",children:[n.jsx("dt",{className:"label",children:"1박 최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:f})]}),u.map(N=>n.jsxs("div",{className:"px-5 text-center",children:[n.jsx("dt",{className:"label",children:N.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:N.value})]},N.label))]}),n.jsxs("div",{className:"flex shrink-0 gap-2",children:[c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border inline-flex items-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:c.phone})]}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]})]})})]})}function Rh({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full bg-current/15 px-3 backdrop-blur-sm transition-colors hover:bg-current/25 sm:inline-flex ${i==="prev"?"left-4":"right-4"}`,children:n.jsx(o,{className:"size-5"})})}function av(){const i=le(),{place:c,narrative:o}=i,u=Oa(i).slice(0,3),f=En(i),d=Ze(i),h=H0(i,"extra_person_fee"),p=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary))[0],g=c.addressLocality??c.addressRegion;return n.jsx("section",{id:"top",className:"border-line w-full border-b",children:n.jsxs("div",{className:"grid lg:grid-cols-2",children:[n.jsx("div",{className:"relative order-1 min-h-[16rem] lg:order-none lg:min-h-[34rem]",children:p&&n.jsx("img",{src:p.url,alt:p.alt,fetchPriority:"high",decoding:"async",className:"absolute inset-0 size-full object-cover"})}),n.jsxs("div",{className:"order-2 flex flex-col justify-center gap-6 px-6 py-10 sm:px-10 lg:order-none lg:px-14 lg:py-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:[n.jsxs("div",{children:[g&&n.jsx("p",{className:"label",children:g}),n.jsx("h1",{className:"serif mt-2",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:c.name}),n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80",children:o.tagline??o.heroSubline??o.summary})]}),n.jsxs("div",{className:"panel p-5 sm:p-6",children:[f&&n.jsxs("p",{className:"flex items-baseline gap-2",children:[n.jsx("span",{className:"text-[length:var(--fs-h3)] font-bold tabular-nums",children:f}),n.jsx("span",{className:"text-muted text-[length:var(--fs-xs)]",children:"1박 기준"})]}),u.length>0&&n.jsxs("dl",{className:"divide-line mt-4 divide-y border-t pt-1",children:[u.map(x=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{className:"font-semibold",children:x.value})]},x.label)),h&&n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted",children:h.label}),n.jsx("dd",{className:"font-semibold tabular-nums",children:h.value})]})]}),n.jsxs("div",{className:"mt-5 flex flex-col gap-2 sm:flex-row",children:[d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])}),c.phone&&n.jsxs("a",{href:`tel:${c.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:c.phone})]})]})]})]})]})})}const nv={"stay-reservation":"reservation","stay-oasi":"oasi","stay-studio":"studio","stay-pastel":"pastel","stay-editorial":"editorial"};function zp(i){return nv[i??""]??"default"}function hc(){return zp(le().theme.templateId)}const rc=5,Xo=1.8,sv=3;function iv(i){const c=i*rc,o=f=>`${(f/c*100).toFixed(3)}%`;return`${i<2?"":` +.stay2-hero-slide { animation: stay2-hero-fade ${c}s ease-in-out infinite; } +@keyframes stay2-hero-fade { + 0% { opacity: 1; } + ${o(rc)} { opacity: 1; } + ${o(rc+Xo)} { opacity: 0; } + ${o(c-Xo)} { opacity: 0; } + 100% { opacity: 1; } +}`} +.stay2-hero-focus { animation: stay2-hero-focus 1.6s cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes stay2-hero-focus { + 0% { opacity: 0; filter: blur(0.22em); } + 40% { opacity: 1; } + 100% { opacity: 1; filter: blur(0); } +} +@media (prefers-reduced-motion: reduce) { + .stay2-hero-slide, .stay2-hero-focus { animation: none; } +} +`}function cv(i,c){return((c-i)%c*rc+Xo).toFixed(1)}function rv(){const i=le(),{place:c,narrative:o}=i,u=ft(i),f=Ze(i)[0],d=o.tagline??o.heroSubline,h=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)),p=h.length<2?0:Math.min(h.length,sv),g=f?{href:f.url,label:`${ks(f)}에서 예약`,external:!0}:c.phone?{href:`tel:${c.phone}`,label:`${c.phone} 예약 문의`,external:!1}:i.units.length>0?{href:"#units",label:`${u.label} 보기`,external:!1}:null;return n.jsxs("section",{id:"top",className:"w-full",children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:iv(p)}}),n.jsxs("div",{className:"relative flex w-full items-center justify-center overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",height:"100svh"},children:[h.map((x,v)=>n.jsx("img",{src:x.url,alt:x.alt,fetchPriority:v===0?"high":v<p?"auto":"low",loading:v<Math.max(p,1)?void 0:"lazy",decoding:"async",width:x.width,height:x.height,className:`absolute inset-0 size-full object-cover object-center ${v<p?"stay2-hero-slide":""}`,style:{opacity:v===0?1:0,animationDelay:v<p?`-${cv(v,p)}s`:void 0}},x.mediaId)),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 14%, transparent) 40%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 h-48",style:{background:"linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 68%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 40%, transparent) 45%, transparent 100%)"}}),n.jsx("div",{className:"pointer-events-none absolute inset-0",style:{background:"radial-gradient(ellipse 58% 42% at 50% 52%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 38%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 18%, transparent) 55%, transparent 78%)"}}),n.jsxs("div",{className:"shell relative z-10 flex flex-col items-center pt-20 text-center",style:{textShadow:"0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)"},children:[n.jsx("h1",{className:"stay2-hero-focus serif",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.06em",lineHeight:1.25,animationDelay:"0.05s"},children:c.name}),d&&n.jsx("p",{className:"stay2-hero-focus measure mt-6 text-[length:var(--fs-lead)] font-light leading-loose opacity-95",style:{animationDelay:"0.3s"},children:d}),g&&n.jsx("a",{href:g.href,...g.external?{target:"_blank",rel:"noopener noreferrer"}:{},className:"stay2-hero-focus tap mt-10 inline-flex items-center justify-center border border-current/70 px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-colors hover:border-current",style:{animationDelay:"0.55s",backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 22%, transparent)",boxShadow:"0 1px 6px color-mix(in srgb, var(--tpl-inverse, #1c1917) 30%, transparent)"},children:g.label})]})]})]})}function ov(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=En(i),d=Ze(i),h=Oa(i),p=h.length>0?h:dc(i).slice(0,4),g=(u==null?void 0:u.caption)??c.name;return n.jsxs("section",{id:"top","data-oasi":!0,"aria-labelledby":"top-heading",className:"pt-12 lg:pt-16",children:[n.jsx("h1",{id:"top-heading",className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em] leading-[1.9]",children:c.name}),o.heroHeadline&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o.heroHeadline}),u&&n.jsxs("figure",{className:"relative mx-auto mt-10 w-full max-w-[34rem]",children:[n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"block h-[clamp(17rem,27vw,24rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:g})]}),(f||p.length>0)&&n.jsxs("dl",{className:"border-line mt-10 flex flex-wrap gap-x-10 gap-y-4 border-t pt-5 text-[length:var(--fs-xs)] leading-[1.9]",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:"최저가"}),n.jsx("dd",{children:f})]}),p.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-muted",children:x.label}),n.jsx("dd",{children:x.value})]},x.label))]}),d.length>0&&n.jsx("ul",{className:"mt-6 flex flex-wrap items-center gap-x-8",children:d.map(x=>n.jsx("li",{children:n.jsx("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(x)})},x.url))})]})}function uv(){const i=le(),c=lu(i),o=mc(i),u=o&&c.some(f=>f.mediaId===o.mediaId)?[o,...c.filter(f=>f.mediaId!==o.mediaId)]:c;return u.length===0?null:n.jsx("section",{id:"top","aria-label":`${i.place.name} 사진`,className:"w-full",children:n.jsx("div",{className:"columns-1 gap-2 sm:columns-2",children:u.map((f,d)=>n.jsx("figure",{className:"mb-2 break-inside-avoid",children:n.jsx("img",{src:f.url,alt:f.alt,loading:d===0?"eager":"lazy",fetchPriority:d===0?"high":void 0,decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})})}function fv(){const i=le(),{place:c,narrative:o}=i,u=Oa(i),f=En(i),d=Ze(i),h=o.tagline??o.summary,p=c.addressLocality??c.addressRegion,g=[...i.media].filter(x=>{var v;return(v=x.alt)==null?void 0:v.trim()}).sort((x,v)=>Number(v.isPrimary)-Number(x.isPrimary)).slice(0,3);return n.jsxs("section",{id:"top","data-pastel":"own",className:"w-full",children:[n.jsxs("div",{className:"relative w-full overflow-hidden",style:{backgroundColor:"var(--pastel-1)"},children:[n.jsxs("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-0",children:[n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(12rem, 26vw, 22rem)",aspectRatio:"1",backgroundColor:"var(--pastel-2)",right:"-6%",top:"-6%",opacity:.75}}),n.jsx("span",{className:"absolute rounded-full",style:{width:"clamp(8rem, 18vw, 15rem)",aspectRatio:"1",backgroundColor:"var(--pastel-3)",left:"46%",bottom:"-10%",opacity:.7}})]}),n.jsxs("div",{className:"shell relative grid items-center gap-8 lg:grid-cols-2 lg:gap-14",style:{paddingBlock:"var(--section-space)"},children:[n.jsxs("div",{className:"order-2 lg:order-1",children:[p&&n.jsx("p",{className:"inline-flex items-center rounded-full px-3 py-1 text-[length:var(--fs-xs)] font-extrabold",style:{backgroundColor:"var(--tpl-bg, #fff)",color:"var(--pastel-ink)"},children:p}),n.jsx("h1",{className:"mt-4 break-keep",style:{fontSize:"var(--fs-display)",fontWeight:800,lineHeight:1.2,letterSpacing:"var(--tpl-heading-tracking, -0.03em)"},children:c.name}),h&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80",children:h}),d.length>0&&n.jsx("a",{href:d[0].url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(d[0])})]}),n.jsxs("div",{className:"order-1 lg:order-2",children:[g[0]&&n.jsx("div",{className:"relative w-full overflow-hidden",style:{aspectRatio:"1 / 1",borderRadius:"calc(var(--tpl-radius, 24px) * 1.6)",boxShadow:"0 0 0 clamp(6px, 1.4vw, 12px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:g[0].url,alt:g[0].alt,fetchPriority:"high",decoding:"async",className:"size-full object-cover"})}),g.length>1&&n.jsx("ul",{className:"mt-3 grid grid-cols-2 gap-3",children:g.slice(1).map(x=>n.jsx("li",{className:"relative overflow-hidden",style:{aspectRatio:"4 / 3",borderRadius:"var(--tpl-radius, 24px)",boxShadow:"0 0 0 clamp(4px, 1vw, 8px) var(--tpl-bg, #fff)"},children:n.jsx("img",{src:x.url,alt:x.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},x.mediaId))})]})]})]}),(f||u.length>0)&&n.jsx("div",{className:"w-full",style:{backgroundColor:"var(--pastel-2)"},children:n.jsxs("dl",{className:"shell flex flex-wrap items-center justify-center gap-x-12 gap-y-5 py-8 text-center",children:[f&&n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:"최저가"}),n.jsx("dd",{className:"mt-1 font-extrabold tabular-nums",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:f})]}),u.map(x=>n.jsxs("div",{children:[n.jsx("dt",{className:"text-[length:var(--fs-xs)] font-extrabold",style:{color:"var(--tpl-bg, #fff)",opacity:.85},children:x.label}),n.jsx("dd",{className:"mt-1 font-extrabold",style:{fontSize:"var(--fs-h3)",color:"var(--tpl-bg, #fff)"},children:x.value})]},x.label))]})})]})}const Os="repeating-linear-gradient(90deg, color-mix(in oklab, currentColor 3%, transparent) 0 1px, transparent 1px 6px),repeating-linear-gradient(0deg, color-mix(in oklab, currentColor 2%, transparent) 0 1px, transparent 1px 6px)",dv={intro:"about",info:"info",rooms:"units",menu:"units",programs:"units",pricing:"pricing",booking:"booking",space:"space",inquiry:"inquiry",exhibition:"exhibition",photos:"gallery",local:"guide",weather:"weather",map:"location",faq:"faq",songs:"songs",daily:"daily",chronicle:"chronicle",people:"people",quiz:"quiz",postcard:"postcard",video:"video",itinerary:"itinerary"},mv={about:"소개",info:"이용 정보",pricing:"요금",booking:"예약 안내",space:"공간",inquiry:"문의",exhibition:"관람 안내",gallery:"사진",guide:"주변",weather:"날씨",location:"오시는 길",faq:"자주 묻는 질문",songs:"노래",daily:"일력",chronicle:"연표",people:"인물",quiz:"퀴즈",postcard:"엽서",video:"영상",itinerary:"일정"};function Uh(i){return String(i).padStart(2,"0")}function Ep(){var f;const i=le(),c=ft(i),o=new Set,u=[];for(const d of R0(i)){const h=dv[d.id];if(!h||o.has(h)||h==="units"&&i.units.length===0)continue;o.add(h);const p=(f=d.name)==null?void 0:f.trim();u.push({no:Uh(u.length+1),label:p||mv[h]||c.label,anchor:h})}return o.has("location")||u.push({no:Uh(u.length+1),label:"오시는 길",anchor:"location"}),u}function au({id:i,title:c,lead:o,aside:u}){var d;const f=(d=Ep().find(h=>h.anchor===i))==null?void 0:d.no;return n.jsxs("header",{className:"mb-7 sm:mb-10",children:[n.jsx("div",{"aria-hidden":!0,className:"mb-5 h-0.5 w-full bg-current opacity-75"}),n.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-10",children:[n.jsxs("div",{className:"relative min-w-0 flex-1",children:[f&&n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 select-none leading-none tabular-nums",style:{fontSize:"clamp(2.5rem, 7vw, 5rem)",fontWeight:800,opacity:.12},children:f}),n.jsx("h2",{id:`${i}-heading`,className:"h2 relative pl-9 sm:pl-14 xl:pl-20",style:{fontWeight:800},children:c}),o&&n.jsx("p",{className:"text-muted measure relative mt-2 pl-9 font-serif text-[length:var(--fs-sm)] leading-relaxed sm:pl-14 xl:pl-20",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})]})}function hv(){const i=le(),{place:c,narrative:o}=i,u=mc(i),f=ft(i),d=fl(i),h=Oa(i),p=En(i),g=Ze(i)[0],x=wn(i)[0],v=o.tagline??o.heroSubline,z=[c.addressSubLocality??c.addressLocality??c.addressRegion,d.length>0?`${f.label} ${d.length}개`:void 0,p].filter(w=>!!w);return n.jsx("section",{id:"top",className:"relative w-full",style:{backgroundImage:Os},children:n.jsxs("div",{className:"grid grid-cols-12",children:[u&&n.jsx("div",{className:"relative col-span-12 xl:col-span-8 xl:col-start-5 xl:row-start-1",style:{minHeight:"clamp(15rem, 52vh, 34rem)",backgroundColor:"var(--tpl-inverse)"},children:n.jsx("img",{src:u.url,alt:u.alt,fetchPriority:"high",decoding:"async",width:u.width,height:u.height,className:"absolute inset-0 size-full object-cover"})}),n.jsx("div",{className:"z-10 col-span-12 xl:col-start-1 xl:row-start-1 xl:mb-12 xl:mr-16 xl:self-end",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse) 88%, transparent)",color:"var(--tpl-bg)"},children:n.jsx("h1",{className:"serif px-4 py-5 sm:px-6 xl:py-8 xl:pl-10 xl:pr-16",style:{fontSize:"clamp(2.25rem, 7.5vw, 5.5rem)",fontWeight:800,lineHeight:.98,letterSpacing:"-0.035em"},children:c.name})}),n.jsxs("div",{className:"col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8",children:[v&&n.jsx("p",{className:"measure font-serif text-[length:var(--fs-lead)] leading-relaxed",children:v}),z.length>0&&n.jsx("p",{className:"text-[length:var(--fs-sm)] font-semibold tabular-nums",children:z.join(" · ")}),(g||c.phone||x)&&n.jsxs("div",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(g)}),c.phone&&n.jsx("a",{href:`tel:${c.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:c.phone}),x&&n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:[ml(x)," 문의"]})]}),h.length>0&&n.jsx("dl",{className:"mt-1",children:h.map(w=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-4 border-t py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:w.label}),n.jsx("dd",{className:"text-right font-semibold",children:w.value})]},w.label))})]})]})})}function pv(){var g;const i=le(),c=hc();if(c==="reservation")return n.jsx(rv,{});if(c==="oasi")return n.jsx(ov,{});if(c==="studio")return n.jsx(uv,{});if(c==="pastel")return n.jsx(fv,{});if(c==="editorial")return n.jsx(hv,{});const o=(g=i.theme.sections.find(x=>x.id==="hero"))==null?void 0:g.variantId;if(o==="hero.slideshow")return n.jsx(lv,{});if(o==="hero.split")return n.jsx(av,{});const{place:u,narrative:f}=i,d=mc(i),h=ft(i);Ze(i);const p=u.addressLocality??u.addressRegion;return n.jsx("section",{id:"top",className:"w-full",children:n.jsxs("div",{className:"relative flex w-full items-end overflow-hidden",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)",minHeight:"clamp(24rem, 62vh, 36rem)"},children:[d&&n.jsxs("div",{className:"absolute inset-0 z-0",children:[n.jsx("img",{src:d.url,alt:d.alt,fetchPriority:"high",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover object-center"}),n.jsx("div",{className:"absolute inset-0",style:{background:"linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)"}})]}),n.jsxs("div",{className:"shell relative z-10 pb-10 pt-24 sm:pb-12",children:[p&&n.jsxs("p",{className:"mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80",children:[n.jsx(Ls,{className:"size-3.5"}),n.jsx("span",{children:p})]}),n.jsx("h1",{className:"serif",style:{fontSize:"var(--fs-display)",fontWeight:"var(--tpl-heading-weight, 700)",lineHeight:1.15},children:u.name}),(f.tagline??f.heroSubline??f.summary)&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85",children:f.tagline??f.heroSubline??f.summary}),i.units.length>0&&n.jsxs("a",{href:"#units",className:"mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:[n.jsxs("span",{children:[h.label," 보기"]}),n.jsx(fc,{className:"size-4"})]})]})]})})}function nu(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":`${c.getFullYear()}년 ${c.getMonth()+1}월 ${c.getDate()}일`}function qs(i){if(!i)return"";const c=new Date(i);return Number.isNaN(c.getTime())?"":c.toLocaleDateString("sv-SE",{timeZone:"Asia/Seoul"})}function Vo(i){return`https://search.naver.com/search.naver?query=${encodeURIComponent(i)}`}function xv(i){return`https://www.youtube.com/results?search_query=${encodeURIComponent(i)}`}function su(i){return i.replace(/,/g," ").replace(/\s+/g," ").trim()}function gv(i,c,o){return c==null||o==null?`https://map.kakao.com/link/search/${encodeURIComponent(i)}`:`https://map.kakao.com/link/to/${encodeURIComponent(su(i))},${c},${o}`}function bv(i,c){const o=u=>`${u.lng},${u.lat},${encodeURIComponent(su(u.name))}`;return`https://map.naver.com/p/directions/${o(i)}/${o(c)}/-/car`}function wp(i,c,o){return c==null||o==null?`https://map.naver.com/p/search/${encodeURIComponent(i)}`:`https://map.naver.com/p/directions/-/${o},${c},${encodeURIComponent(su(i))}/-/transit`}function vv(i,c,o=16/9,u=.0022){const f=Math.max(Math.cos(i*Math.PI/180),.01),d=o*u/f;return`https://www.openstreetmap.org/export/embed.html?bbox=${[c-d,i-u,c+d,i+u].map(p=>p.toFixed(6)).join(",")}&layer=mapnik&marker=${i},${c}`}function yv(i,c,o){if(c==null||o==null)return;const u=encodeURIComponent(i);return`tmap://route?goalname=${u}&goalx=${o}&goaly=${c}&rGoName=${u}&rGoX=${o}&rGoY=${c}`}function Hh(){const i=le(),c=dc(i).slice(0,6),o=i.narrative.summary??I0(i);return c.length===0&&!o?null:n.jsx("section",{id:"summary","aria-labelledby":"summary-heading",className:"border-line w-full border-b py-10 sm:py-14",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},children:n.jsx("div",{className:"shell",children:n.jsxs("div",{className:"grid gap-8 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h2",{id:"summary-heading",className:"h3",children:"예약 전 확인"}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed",children:o}),n.jsxs("p",{className:"text-muted mt-3 text-[length:var(--fs-xs)]",children:[nu(i.site.updatedAt)," 기준, 사업자가 확인한 정보입니다."]})]}),c.length>0&&n.jsx("dl",{className:"grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2",children:c.map(u=>n.jsxs("div",{className:"border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28",children:u.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:u.value})]},u.label))})]})})})}function iu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-10 flex flex-col items-center gap-4 text-center sm:mb-14",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif max-w-full",style:{fontSize:"var(--fs-h2)",fontWeight:300,letterSpacing:"0.04em",lineHeight:1.35},children:c}),n.jsx("i",{"aria-hidden":!0,className:"block h-px w-10 bg-current opacity-25"}),o&&n.jsx("p",{className:"text-muted measure text-[length:var(--fs-sm)] leading-loose",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-xs)]",children:u})]})}function cu({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-8",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:["[",c,"]"]}),o&&n.jsx("p",{className:"text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]",children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})}function ru({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-7 sm:mb-10",children:n.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-10",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("h2",{id:`${i}-heading`,className:"serif min-w-0 font-extrabold",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.01em",lineHeight:1.2,color:"var(--tpl-text)"},children:c}),o&&n.jsx("p",{className:"text-muted measure mt-2 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:o})]}),u&&n.jsx("div",{className:"shrink-0 text-[length:var(--fs-xs)]",children:u})]})})}function ou({id:i,title:c,lead:o,aside:u}){return n.jsxs("header",{className:"mb-8 flex flex-col items-center gap-3 text-center sm:mb-10",children:[n.jsx("i",{"aria-hidden":!0,className:"block h-[6px] w-10 rounded-full",style:{backgroundColor:"var(--pastel-2, currentColor)",opacity:.9}}),n.jsx("h2",{id:`${i}-heading`,className:"h2 break-keep",style:{fontSize:"calc(var(--fs-h2) * 1.15)",fontWeight:800},children:c}),o&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-65",children:o}),u&&n.jsx("div",{className:"text-[length:var(--fs-sm)]",children:u})]})}const jv={base:{backgroundColor:"var(--tpl-surface, #ffffff)"},alt:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)"},dark:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"}};function vt({id:i,title:c,lead:o,tone:u="base",aside:f,footnote:d,children:h,wide:p,bare:g}){const x=hc(),v=x==="reservation"?iu:x==="oasi"?cu:x==="studio"?ru:x==="pastel"?ou:x==="editorial"?au:Nv;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{...jv[u],paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:p?"w-full":"shell",children:[!g&&n.jsx("div",{className:p?"shell":void 0,children:n.jsx(v,{id:i,title:c,lead:o,aside:f})}),h,d&&n.jsx("p",{className:`text-muted mt-5 text-[length:var(--fs-xs)] ${p?"shell":""}`,children:d})]})})}function Nv({id:i,title:c,lead:o,aside:u}){return n.jsx("header",{className:"mb-6 sm:mb-8",children:n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsxs("h2",{id:`${i}-heading`,className:"h2 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:c})]}),o&&n.jsx("p",{className:"text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]",children:o})]}),u&&n.jsx("div",{className:"shrink-0",children:u})]})})}function Sv(i){return Object.prototype.toString.call(i)==="[object Object]"}function Lh(i){return Sv(i)||Array.isArray(i)}function zv(){return!!(typeof window<"u"&&window.document&&window.document.createElement)}function uu(i,c){const o=Object.keys(i),u=Object.keys(c);if(o.length!==u.length)return!1;const f=JSON.stringify(Object.keys(i.breakpoints||{})),d=JSON.stringify(Object.keys(c.breakpoints||{}));return f!==d?!1:o.every(h=>{const p=i[h],g=c[h];return typeof p=="function"?`${p}`==`${g}`:!Lh(p)||!Lh(g)?p===g:uu(p,g)})}function Bh(i){return i.concat().sort((c,o)=>c.name>o.name?1:-1).map(c=>c.options)}function Ev(i,c){if(i.length!==c.length)return!1;const o=Bh(i),u=Bh(c);return o.every((f,d)=>{const h=u[d];return uu(f,h)})}function fu(i){return typeof i=="number"}function Zo(i){return typeof i=="string"}function pc(i){return typeof i=="boolean"}function qh(i){return Object.prototype.toString.call(i)==="[object Object]"}function $e(i){return Math.abs(i)}function du(i){return Math.sign(i)}function Ts(i,c){return $e(i-c)}function wv(i,c){if(i===0||c===0||$e(i)<=$e(c))return 0;const o=Ts($e(i),$e(c));return $e(o/i)}function _v(i){return Math.round(i*100)/100}function Ds(i){return Rs(i).map(Number)}function el(i){return i[Ys(i)]}function Ys(i){return Math.max(0,i.length-1)}function mu(i,c){return c===Ys(i)}function Yh(i,c=0){return Array.from(Array(i),(o,u)=>c+u)}function Rs(i){return Object.keys(i)}function _p(i,c){return[i,c].reduce((o,u)=>(Rs(u).forEach(f=>{const d=o[f],h=u[f],p=qh(d)&&qh(h);o[f]=p?_p(d,h):h}),o),{})}function Ko(i,c){return typeof c.MouseEvent<"u"&&i instanceof c.MouseEvent}function Tv(i,c){const o={start:u,center:f,end:d};function u(){return 0}function f(g){return d(g)/2}function d(g){return c-g}function h(g,x){return Zo(i)?o[i](g):i(c,g,x)}return{measure:h}}function Us(){let i=[];function c(f,d,h,p={passive:!0}){let g;if("addEventListener"in f)f.addEventListener(d,h,p),g=()=>f.removeEventListener(d,h,p);else{const x=f;x.addListener(h),g=()=>x.removeListener(h)}return i.push(g),u}function o(){i=i.filter(f=>f())}const u={add:c,clear:o};return u}function Av(i,c,o,u){const f=Us(),d=1e3/60;let h=null,p=0,g=0;function x(){f.add(i,"visibilitychange",()=>{i.hidden&&N()})}function v(){w(),f.clear()}function y(D){if(!g)return;h||(h=D,o(),o());const U=D-h;for(h=D,p+=U;p>=d;)o(),p-=d;const X=p/d;u(X),g&&(g=c.requestAnimationFrame(y))}function z(){g||(g=c.requestAnimationFrame(y))}function w(){c.cancelAnimationFrame(g),h=null,p=0,g=0}function N(){h=null,p=0}return{init:x,destroy:v,start:z,stop:w,update:o,render:u}}function Cv(i,c){const o=c==="rtl",u=i==="y",f=u?"y":"x",d=u?"x":"y",h=!u&&o?-1:1,p=v(),g=y();function x(N){const{height:T,width:D}=N;return u?T:D}function v(){return u?"top":o?"right":"left"}function y(){return u?"bottom":o?"left":"right"}function z(N){return N*h}return{scroll:f,cross:d,startEdge:p,endEdge:g,measureSize:x,direction:z}}function ka(i=0,c=0){const o=$e(i-c);function u(x){return x<i}function f(x){return x>c}function d(x){return u(x)||f(x)}function h(x){return d(x)?u(x)?i:c:x}function p(x){return o?x-o*Math.ceil((x-c)/o):x}return{length:o,max:c,min:i,constrain:h,reachedAny:d,reachedMax:f,reachedMin:u,removeOffset:p}}function Tp(i,c,o){const{constrain:u}=ka(0,i),f=i+1;let d=h(c);function h(z){return o?$e((f+z)%f):u(z)}function p(){return d}function g(z){return d=h(z),y}function x(z){return v().set(p()+z)}function v(){return Tp(i,p(),o)}const y={get:p,set:g,add:x,clone:v};return y}function Mv(i,c,o,u,f,d,h,p,g,x,v,y,z,w,N,T,D,U,X){const{cross:Z,direction:P}=i,$=["INPUT","SELECT","TEXTAREA"],K={passive:!1},G=Us(),ee=Us(),se=ka(50,225).constrain(w.measure(20)),Q={mouse:300,touch:400},ae={mouse:500,touch:600},ye=N?43:25;let Ge=!1,Qe=0,ce=0,k=!1,Y=!1,te=!1,he=!1;function je(I){if(!X)return;function _e(tt){(pc(X)||X(I,tt))&&fe(tt)}const ke=c;G.add(ke,"dragstart",tt=>tt.preventDefault(),K).add(ke,"touchmove",()=>{},K).add(ke,"touchend",()=>{}).add(ke,"touchstart",_e).add(ke,"mousedown",_e).add(ke,"touchcancel",ze).add(ke,"contextmenu",ze).add(ke,"click",Ee,!0)}function S(){G.clear(),ee.clear()}function L(){const I=he?o:c;ee.add(I,"touchmove",oe,K).add(I,"touchend",ze).add(I,"mousemove",oe,K).add(I,"mouseup",ze)}function V(I){const _e=I.nodeName||"";return $.includes(_e)}function J(){return(N?ae:Q)[he?"mouse":"touch"]}function re(I,_e){const ke=y.add(du(I)*-1),tt=v.byDistance(I,!N).distance;return N||$e(I)<se?tt:D&&_e?tt*.5:v.byIndex(ke.get(),0).distance}function fe(I){const _e=Ko(I,u);he=_e,te=N&&_e&&!I.buttons&&Ge,Ge=Ts(f.get(),h.get())>=2,!(_e&&I.button!==0)&&(V(I.target)||(k=!0,d.pointerDown(I),x.useFriction(0).useDuration(0),f.set(h),L(),Qe=d.readPoint(I),ce=d.readPoint(I,Z),z.emit("pointerDown")))}function oe(I){if(!Ko(I,u)&&I.touches.length>=2)return ze(I);const ke=d.readPoint(I),tt=d.readPoint(I,Z),yt=Ts(ke,Qe),Jt=Ts(tt,ce);if(!Y&&!he&&(!I.cancelable||(Y=yt>Jt,!Y)))return ze(I);const ll=d.pointerMove(I);yt>T&&(te=!0),x.useFriction(.3).useDuration(.75),p.start(),f.add(P(ll)),I.preventDefault()}function ze(I){const ke=v.byDistance(0,!1).index!==y.get(),tt=d.pointerUp(I)*J(),yt=re(P(tt),ke),Jt=wv(tt,yt),ll=ye-10*Jt,Ht=U+Jt/50;Y=!1,k=!1,ee.clear(),x.useDuration(ll).useFriction(Ht),g.distance(yt,!N),he=!1,z.emit("pointerUp")}function Ee(I){te&&(I.stopPropagation(),I.preventDefault(),te=!1)}function at(){return k}return{init:je,destroy:S,pointerDown:at}}function kv(i,c){let u,f;function d(y){return y.timeStamp}function h(y,z){const N=`client${(z||i.scroll)==="x"?"X":"Y"}`;return(Ko(y,c)?y:y.touches[0])[N]}function p(y){return u=y,f=y,h(y)}function g(y){const z=h(y)-h(f),w=d(y)-d(u)>170;return f=y,w&&(u=y),z}function x(y){if(!u||!f)return 0;const z=h(f)-h(u),w=d(y)-d(u),N=d(y)-d(f)>170,T=z/w;return w&&!N&&$e(T)>.1?T:0}return{pointerDown:p,pointerMove:g,pointerUp:x,readPoint:h}}function Ov(){function i(o){const{offsetTop:u,offsetLeft:f,offsetWidth:d,offsetHeight:h}=o;return{top:u,right:f+d,bottom:u+h,left:f,width:d,height:h}}return{measure:i}}function Dv(i){function c(u){return i*(u/100)}return{measure:c}}function Rv(i,c,o,u,f,d,h){const p=[i].concat(u);let g,x,v=[],y=!1;function z(D){return f.measureSize(h.measure(D))}function w(D){if(!d)return;x=z(i),v=u.map(z);function U(X){for(const Z of X){if(y)return;const P=Z.target===i,$=u.indexOf(Z.target),K=P?x:v[$],G=z(P?i:u[$]);if($e(G-K)>=.5){D.reInit(),c.emit("resize");break}}}g=new ResizeObserver(X=>{(pc(d)||d(D,X))&&U(X)}),o.requestAnimationFrame(()=>{p.forEach(X=>g.observe(X))})}function N(){y=!0,g&&g.disconnect()}return{init:w,destroy:N}}function Uv(i,c,o,u,f,d){let h=0,p=0,g=f,x=d,v=i.get(),y=0;function z(){const K=u.get()-i.get(),G=!g;let ee=0;return G?(h=0,o.set(u),i.set(u),ee=K):(o.set(i),h+=K/g,h*=x,v+=h,i.add(h),ee=v-y),p=du(ee),y=v,$}function w(){const K=u.get()-c.get();return $e(K)<.001}function N(){return g}function T(){return p}function D(){return h}function U(){return Z(f)}function X(){return P(d)}function Z(K){return g=K,$}function P(K){return x=K,$}const $={direction:T,duration:N,velocity:D,seek:z,settled:w,useBaseFriction:X,useBaseDuration:U,useFriction:P,useDuration:Z};return $}function Hv(i,c,o,u,f){const d=f.measure(10),h=f.measure(50),p=ka(.1,.99);let g=!1;function x(){return!(g||!i.reachedAny(o.get())||!i.reachedAny(c.get()))}function v(w){if(!x())return;const N=i.reachedMin(c.get())?"min":"max",T=$e(i[N]-c.get()),D=o.get()-c.get(),U=p.constrain(T/h);o.subtract(D*U),!w&&$e(D)<d&&(o.set(i.constrain(o.get())),u.useDuration(25).useBaseFriction())}function y(w){g=!w}return{shouldConstrain:x,constrain:v,toggleActive:y}}function Lv(i,c,o,u,f){const d=ka(-c+i,0),h=y(),p=v(),g=z();function x(N,T){return Ts(N,T)<=1}function v(){const N=h[0],T=el(h),D=h.lastIndexOf(N),U=h.indexOf(T)+1;return ka(D,U)}function y(){return o.map((N,T)=>{const{min:D,max:U}=d,X=d.constrain(N),Z=!T,P=mu(o,T);return Z?U:P||x(D,X)?D:x(U,X)?U:X}).map(N=>parseFloat(N.toFixed(3)))}function z(){if(c<=i+f)return[d.max];if(u==="keepSnaps")return h;const{min:N,max:T}=p;return h.slice(N,T)}return{snapsContained:g,scrollContainLimit:p}}function Bv(i,c,o){const u=c[0],f=o?u-i:el(c);return{limit:ka(f,u)}}function qv(i,c,o,u){const d=c.min+.1,h=c.max+.1,{reachedMin:p,reachedMax:g}=ka(d,h);function x(z){return z===1?g(o.get()):z===-1?p(o.get()):!1}function v(z){if(!x(z))return;const w=i*(z*-1);u.forEach(N=>N.add(w))}return{loop:v}}function Yv(i){const{max:c,length:o}=i;function u(d){const h=d-c;return o?h/-o:0}return{get:u}}function Gv(i,c,o,u,f){const{startEdge:d,endEdge:h}=i,{groupSlides:p}=f,g=y().map(c.measure),x=z(),v=w();function y(){return p(u).map(T=>el(T)[h]-T[0][d]).map($e)}function z(){return u.map(T=>o[d]-T[d]).map(T=>-$e(T))}function w(){return p(x).map(T=>T[0]).map((T,D)=>T+g[D])}return{snaps:x,snapsAligned:v}}function $v(i,c,o,u,f,d){const{groupSlides:h}=f,{min:p,max:g}=u,x=v();function v(){const z=h(d),w=!i||c==="keepSnaps";return o.length===1?[d]:w?z:z.slice(p,g).map((N,T,D)=>{const U=!T,X=mu(D,T);if(U){const Z=el(D[0])+1;return Yh(Z)}if(X){const Z=Ys(d)-el(D)[0]+1;return Yh(Z,el(D)[0])}return N})}return{slideRegistry:x}}function Qv(i,c,o,u,f){const{reachedAny:d,removeOffset:h,constrain:p}=u;function g(N){return N.concat().sort((T,D)=>$e(T)-$e(D))[0]}function x(N){const T=i?h(N):p(N),D=c.map((X,Z)=>({diff:v(X-T,0),index:Z})).sort((X,Z)=>$e(X.diff)-$e(Z.diff)),{index:U}=D[0];return{index:U,distance:T}}function v(N,T){const D=[N,N+o,N-o];if(!i)return N;if(!T)return g(D);const U=D.filter(X=>du(X)===T);return U.length?g(U):el(D)-o}function y(N,T){const D=c[N]-f.get(),U=v(D,T);return{index:N,distance:U}}function z(N,T){const D=f.get()+N,{index:U,distance:X}=x(D),Z=!i&&d(D);if(!T||Z)return{index:U,distance:N};const P=c[U]-X,$=N+v(P,0);return{index:U,distance:$}}return{byDistance:z,byIndex:y,shortcut:v}}function Xv(i,c,o,u,f,d,h){function p(y){const z=y.distance,w=y.index!==c.get();d.add(z),z&&(u.duration()?i.start():(i.update(),i.render(1),i.update())),w&&(o.set(c.get()),c.set(y.index),h.emit("select"))}function g(y,z){const w=f.byDistance(y,z);p(w)}function x(y,z){const w=c.clone().set(y),N=f.byIndex(w.get(),z);p(N)}return{distance:g,index:x}}function Vv(i,c,o,u,f,d,h,p){const g={passive:!0,capture:!0};let x=0;function v(w){if(!p)return;function N(T){if(new Date().getTime()-x>10)return;h.emit("slideFocusStart"),i.scrollLeft=0;const X=o.findIndex(Z=>Z.includes(T));fu(X)&&(f.useDuration(0),u.index(X,0),h.emit("slideFocus"))}d.add(document,"keydown",y,!1),c.forEach((T,D)=>{d.add(T,"focus",U=>{(pc(p)||p(w,U))&&N(D)},g)})}function y(w){w.code==="Tab"&&(x=new Date().getTime())}return{init:v}}function ws(i){let c=i;function o(){return c}function u(g){c=h(g)}function f(g){c+=h(g)}function d(g){c-=h(g)}function h(g){return fu(g)?g:g.get()}return{get:o,set:u,add:f,subtract:d}}function Ap(i,c){const o=i.scroll==="x"?h:p,u=c.style;let f=null,d=!1;function h(z){return`translate3d(${z}px,0px,0px)`}function p(z){return`translate3d(0px,${z}px,0px)`}function g(z){if(d)return;const w=_v(i.direction(z));w!==f&&(u.transform=o(w),f=w)}function x(z){d=!z}function v(){d||(u.transform="",c.getAttribute("style")||c.removeAttribute("style"))}return{clear:v,to:g,toggleActive:x}}function Zv(i,c,o,u,f,d,h,p,g){const v=Ds(f),y=Ds(f).reverse(),z=U().concat(X());function w(G,ee){return G.reduce((se,Q)=>se-f[Q],ee)}function N(G,ee){return G.reduce((se,Q)=>w(se,ee)>0?se.concat([Q]):se,[])}function T(G){return d.map((ee,se)=>({start:ee-u[se]+.5+G,end:ee+c-.5+G}))}function D(G,ee,se){const Q=T(ee);return G.map(ae=>{const ye=se?0:-o,Ge=se?o:0,Qe=se?"end":"start",ce=Q[ae][Qe];return{index:ae,loopPoint:ce,slideLocation:ws(-1),translate:Ap(i,g[ae]),target:()=>p.get()>ce?ye:Ge}})}function U(){const G=h[0],ee=N(y,G);return D(ee,o,!1)}function X(){const G=c-h[0]-1,ee=N(v,G);return D(ee,-o,!0)}function Z(){return z.every(({index:G})=>{const ee=v.filter(se=>se!==G);return w(ee,c)<=.1})}function P(){z.forEach(G=>{const{target:ee,translate:se,slideLocation:Q}=G,ae=ee();ae!==Q.get()&&(se.to(ae),Q.set(ae))})}function $(){z.forEach(G=>G.translate.clear())}return{canLoop:Z,clear:$,loop:P,loopPoints:z}}function Kv(i,c,o){let u,f=!1;function d(g){if(!o)return;function x(v){for(const y of v)if(y.type==="childList"){g.reInit(),c.emit("slidesChanged");break}}u=new MutationObserver(v=>{f||(pc(o)||o(g,v))&&x(v)}),u.observe(i,{childList:!0})}function h(){u&&u.disconnect(),f=!0}return{init:d,destroy:h}}function Jv(i,c,o,u){const f={};let d=null,h=null,p,g=!1;function x(){p=new IntersectionObserver(N=>{g||(N.forEach(T=>{const D=c.indexOf(T.target);f[D]=T}),d=null,h=null,o.emit("slidesInView"))},{root:i.parentElement,threshold:u}),c.forEach(N=>p.observe(N))}function v(){p&&p.disconnect(),g=!0}function y(N){return Rs(f).reduce((T,D)=>{const U=parseInt(D),{isIntersecting:X}=f[U];return(N&&X||!N&&!X)&&T.push(U),T},[])}function z(N=!0){if(N&&d)return d;if(!N&&h)return h;const T=y(N);return N&&(d=T),N||(h=T),T}return{init:x,destroy:v,get:z}}function Wv(i,c,o,u,f,d){const{measureSize:h,startEdge:p,endEdge:g}=i,x=o[0]&&f,v=N(),y=T(),z=o.map(h),w=D();function N(){if(!x)return 0;const X=o[0];return $e(c[p]-X[p])}function T(){if(!x)return 0;const X=d.getComputedStyle(el(u));return parseFloat(X.getPropertyValue(`margin-${g}`))}function D(){return o.map((X,Z,P)=>{const $=!Z,K=mu(P,Z);return $?z[Z]+v:K?z[Z]+y:P[Z+1][p]-X[p]}).map($e)}return{slideSizes:z,slideSizesWithGaps:w,startGap:v,endGap:y}}function Fv(i,c,o,u,f,d,h,p,g){const{startEdge:x,endEdge:v,direction:y}=i,z=fu(o);function w(U,X){return Ds(U).filter(Z=>Z%X===0).map(Z=>U.slice(Z,Z+X))}function N(U){return U.length?Ds(U).reduce((X,Z,P)=>{const $=el(X)||0,K=$===0,G=Z===Ys(U),ee=f[x]-d[$][x],se=f[x]-d[Z][v],Q=!u&&K?y(h):0,ae=!u&&G?y(p):0,ye=$e(se-ae-(ee+Q));return P&&ye>c+g&&X.push(Z),G&&X.push(U.length),X},[]).map((X,Z,P)=>{const $=Math.max(P[Z-1]||0);return U.slice($,X)}):[]}function T(U){return z?w(U,o):N(U)}return{groupSlides:T}}function Iv(i,c,o,u,f,d,h){const{align:p,axis:g,direction:x,startIndex:v,loop:y,duration:z,dragFree:w,dragThreshold:N,inViewThreshold:T,slidesToScroll:D,skipSnaps:U,containScroll:X,watchResize:Z,watchSlides:P,watchDrag:$,watchFocus:K}=d,G=2,ee=Ov(),se=ee.measure(c),Q=o.map(ee.measure),ae=Cv(g,x),ye=ae.measureSize(se),Ge=Dv(ye),Qe=Tv(p,ye),ce=!y&&!!X,k=y||!!X,{slideSizes:Y,slideSizesWithGaps:te,startGap:he,endGap:je}=Wv(ae,se,Q,o,k,f),S=Fv(ae,ye,D,y,se,Q,he,je,G),{snaps:L,snapsAligned:V}=Gv(ae,Qe,se,Q,S),J=-el(L)+el(te),{snapsContained:re,scrollContainLimit:fe}=Lv(ye,J,V,X,G),oe=ce?re:V,{limit:ze}=Bv(J,oe,y),Ee=Tp(Ys(oe),v,y),at=Ee.clone(),Me=Ds(o),I=({dragHandler:pl,scrollBody:kn,scrollBounds:On,options:{loop:al}})=>{al||On.constrain(pl.pointerDown()),kn.seek()},_e=({scrollBody:pl,translate:kn,location:On,offsetLocation:al,previousLocation:pt,scrollLooper:nl,slideLooper:xt,dragHandler:xc,animation:gc,eventHandler:Xs,scrollBounds:Ra,options:{loop:da}},ma)=>{const sl=pl.settled(),Ua=!Ra.shouldConstrain(),Ul=da?sl:sl&&Ua,Vs=Ul&&!xc.pointerDown();Vs&&gc.stop();const Zs=On.get()*ma+pt.get()*(1-ma);al.set(Zs),da&&(nl.loop(pl.direction()),xt.loop()),kn.to(al.get()),Vs&&Xs.emit("settle"),Ul||Xs.emit("scroll")},ke=Av(u,f,()=>I(Mn),pl=>_e(Mn,pl)),tt=.68,yt=oe[Ee.get()],Jt=ws(yt),ll=ws(yt),Ht=ws(yt),Wt=ws(yt),hl=Uv(Jt,Ht,ll,Wt,z,tt),Tn=Qv(y,oe,J,ze,Wt),An=Xv(ke,Ee,at,hl,Tn,Wt,h),dt=Yv(ze),$s=Us(),Qs=Jv(c,o,h,T),{slideRegistry:Cn}=$v(ce,X,oe,fe,S,Me),Da=Vv(i,o,Cn,An,hl,$s,h,K),Mn={ownerDocument:u,ownerWindow:f,eventHandler:h,containerRect:se,slideRects:Q,animation:ke,axis:ae,dragHandler:Mv(ae,i,u,f,Wt,kv(ae,f),Jt,ke,An,hl,Tn,Ee,h,Ge,w,N,U,tt,$),eventStore:$s,percentOfView:Ge,index:Ee,indexPrevious:at,limit:ze,location:Jt,offsetLocation:Ht,previousLocation:ll,options:d,resizeHandler:Rv(c,h,f,o,ae,Z,ee),scrollBody:hl,scrollBounds:Hv(ze,Ht,Wt,hl,Ge),scrollLooper:qv(J,ze,Ht,[Jt,Ht,ll,Wt]),scrollProgress:dt,scrollSnapList:oe.map(dt.get),scrollSnaps:oe,scrollTarget:Tn,scrollTo:An,slideLooper:Zv(ae,ye,J,Y,te,L,oe,Ht,o),slideFocus:Da,slidesHandler:Kv(c,h,P),slidesInView:Qs,slideIndexes:Me,slideRegistry:Cn,slidesToScroll:S,target:Wt,translate:Ap(ae,c)};return Mn}function Pv(){let i={},c;function o(x){c=x}function u(x){return i[x]||[]}function f(x){return u(x).forEach(v=>v(c,x)),g}function d(x,v){return i[x]=u(x).concat([v]),g}function h(x,v){return i[x]=u(x).filter(y=>y!==v),g}function p(){i={}}const g={init:o,emit:f,off:h,on:d,clear:p};return g}const ey={align:"center",axis:"x",container:null,slides:null,containScroll:"trimSnaps",direction:"ltr",slidesToScroll:1,inViewThreshold:0,breakpoints:{},dragFree:!1,dragThreshold:10,loop:!1,skipSnaps:!1,duration:25,startIndex:0,active:!0,watchDrag:!0,watchResize:!0,watchSlides:!0,watchFocus:!0};function ty(i){function c(d,h){return _p(d,h||{})}function o(d){const h=d.breakpoints||{},p=Rs(h).filter(g=>i.matchMedia(g).matches).map(g=>h[g]).reduce((g,x)=>c(g,x),{});return c(d,p)}function u(d){return d.map(h=>Rs(h.breakpoints||{})).reduce((h,p)=>h.concat(p),[]).map(i.matchMedia)}return{mergeOptions:c,optionsAtMedia:o,optionsMediaQueries:u}}function ly(i){let c=[];function o(d,h){return c=h.filter(({options:p})=>i.optionsAtMedia(p).active!==!1),c.forEach(p=>p.init(d,i)),h.reduce((p,g)=>Object.assign(p,{[g.name]:g}),{})}function u(){c=c.filter(d=>d.destroy())}return{init:o,destroy:u}}function uc(i,c,o){const u=i.ownerDocument,f=u.defaultView,d=ty(f),h=ly(d),p=Us(),g=Pv(),{mergeOptions:x,optionsAtMedia:v,optionsMediaQueries:y}=d,{on:z,off:w,emit:N}=g,T=ae;let D=!1,U,X=x(ey,uc.globalOptions),Z=x(X),P=[],$,K,G;function ee(){const{container:Me,slides:I}=Z;K=(Zo(Me)?i.querySelector(Me):Me)||i.children[0];const ke=Zo(I)?K.querySelectorAll(I):I;G=[].slice.call(ke||K.children)}function se(Me){const I=Iv(i,K,G,u,f,Me,g);if(Me.loop&&!I.slideLooper.canLoop()){const _e=Object.assign({},Me,{loop:!1});return se(_e)}return I}function Q(Me,I){D||(X=x(X,Me),Z=v(X),P=I||P,ee(),U=se(Z),y([X,...P.map(({options:_e})=>_e)]).forEach(_e=>p.add(_e,"change",ae)),Z.active&&(U.translate.to(U.location.get()),U.animation.init(),U.slidesInView.init(),U.slideFocus.init(at),U.eventHandler.init(at),U.resizeHandler.init(at),U.slidesHandler.init(at),U.options.loop&&U.slideLooper.loop(),K.offsetParent&&G.length&&U.dragHandler.init(at),$=h.init(at,P)))}function ae(Me,I){const _e=S();ye(),Q(x({startIndex:_e},Me),I),g.emit("reInit")}function ye(){U.dragHandler.destroy(),U.eventStore.clear(),U.translate.clear(),U.slideLooper.clear(),U.resizeHandler.destroy(),U.slidesHandler.destroy(),U.slidesInView.destroy(),U.animation.destroy(),h.destroy(),p.clear()}function Ge(){D||(D=!0,p.clear(),ye(),g.emit("destroy"),g.clear())}function Qe(Me,I,_e){!Z.active||D||(U.scrollBody.useBaseFriction().useDuration(I===!0?0:Z.duration),U.scrollTo.index(Me,_e||0))}function ce(Me){const I=U.index.add(1).get();Qe(I,Me,-1)}function k(Me){const I=U.index.add(-1).get();Qe(I,Me,1)}function Y(){return U.index.add(1).get()!==S()}function te(){return U.index.add(-1).get()!==S()}function he(){return U.scrollSnapList}function je(){return U.scrollProgress.get(U.offsetLocation.get())}function S(){return U.index.get()}function L(){return U.indexPrevious.get()}function V(){return U.slidesInView.get()}function J(){return U.slidesInView.get(!1)}function re(){return $}function fe(){return U}function oe(){return i}function ze(){return K}function Ee(){return G}const at={canScrollNext:Y,canScrollPrev:te,containerNode:ze,internalEngine:fe,destroy:Ge,off:w,on:z,emit:N,plugins:re,previousScrollSnap:L,reInit:T,rootNode:oe,scrollNext:ce,scrollPrev:k,scrollProgress:je,scrollSnapList:he,scrollTo:Qe,selectedScrollSnap:S,slideNodes:Ee,slidesInView:V,slidesNotInView:J};return Q(c,o),setTimeout(()=>g.emit("init"),0),at}uc.globalOptions=void 0;function hu(i={},c=[]){const o=q.useRef(i),u=q.useRef(c),[f,d]=q.useState(),[h,p]=q.useState(),g=q.useCallback(()=>{f&&f.reInit(o.current,u.current)},[f]);return q.useEffect(()=>{uu(o.current,i)||(o.current=i,g())},[i,g]),q.useEffect(()=>{Ev(u.current,c)||(u.current=c,g())},[c,g]),q.useEffect(()=>{if(zv()&&h){uc.globalOptions=hu.globalOptions;const x=uc(h,o.current,u.current);return d(x),()=>x.destroy()}else d(void 0)},[h,d]),[p,f]}hu.globalOptions=void 0;const pu=4500;function xu({box:i,interval:c,advance:o}){const u=q.useRef(o);u.current=o,q.useEffect(()=>{const f=i.current;if(!f||c<=0||window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;let d=0,h=!0;const p=()=>{d+=1},g=()=>{d=Math.max(0,d-1)},x=window.setInterval(()=>{d>0||document.hidden||!h||u.current()||window.clearInterval(x)},c),v=new IntersectionObserver(([T])=>{h=T.isIntersecting},{threshold:.25});v.observe(f);const y=window.matchMedia("(hover: hover) and (pointer: fine)").matches;y&&(f.addEventListener("pointerenter",p),f.addEventListener("pointerleave",g));let z=!1;const w=T=>{const D=T.target;!(D instanceof Element)||!D.matches(":focus-visible")||z||(z=!0,p())},N=()=>{z&&(z=!1,g())};return f.addEventListener("focusin",w),f.addEventListener("focusout",N),f.addEventListener("pointerdown",p,!0),window.addEventListener("pointerup",g,!0),window.addEventListener("pointercancel",g,!0),()=>{window.clearInterval(x),v.disconnect(),y&&(f.removeEventListener("pointerenter",p),f.removeEventListener("pointerleave",g)),f.removeEventListener("focusin",w),f.removeEventListener("focusout",N),f.removeEventListener("pointerdown",p,!0),window.removeEventListener("pointerup",g,!0),window.removeEventListener("pointercancel",g,!0)}},[i,c])}function Cp(i,c=.85){if(!i)return!1;const o=i.scrollWidth-i.clientWidth;if(o<=8||i.scrollLeft>=o-8)return!1;const u=window.matchMedia("(prefers-reduced-motion: reduce)").matches;return i.scrollBy({left:i.clientWidth*c,behavior:u?"auto":"smooth"}),!0}function gu({label:i,children:c,align:o="start",loop:u=!1,dots:f=!0,arrows:d="header",gap:h=1,autoplay:p=pu,onSelect:g,onReady:x,className:v}){const y=p===!1?0:p,[z,w]=hu({align:o,loop:u,containScroll:"trimSnaps",watchDrag:!0}),[N,T]=q.useState([]),[D,U]=q.useState(0),[X,Z]=q.useState(!1),[P,$]=q.useState(!1),K=q.useRef(null),G=q.useId();q.useEffect(()=>{var Y;if(!w)return;const ce=()=>{U(w.selectedScrollSnap()),Z(w.canScrollPrev()),$(w.canScrollNext())},k=()=>{T(w.scrollSnapList()),ce()};return k(),w.on("select",ce),w.on("reInit",k),(Y=K.current)==null||Y.setAttribute("data-slider","on"),()=>{w.off("select",ce),w.off("reInit",k)}},[w]),q.useEffect(()=>{if(!w)return;const ce=K.current,k=()=>ce==null?void 0:ce.setAttribute("data-dragging","true"),Y=()=>ce==null?void 0:ce.removeAttribute("data-dragging");return w.on("pointerDown",k),w.on("pointerUp",Y),()=>{w.off("pointerDown",k),w.off("pointerUp",Y)}},[w]),xu({box:K,interval:y,advance:()=>!w||w.scrollSnapList().length<=1?!0:w.canScrollNext()?(w.scrollNext(),!0):!1});const ee=q.useCallback(ce=>w==null?void 0:w.scrollTo(ce),[w]),se=q.useRef(g);se.current=g;const Q=q.useRef(x);Q.current=x,q.useEffect(()=>{var k;if(!w)return;const ce=()=>{var Y;return(Y=se.current)==null?void 0:Y.call(se,w.selectedScrollSnap())};return ce(),w.on("select",ce),w.on("reInit",ce),(k=Q.current)==null||k.call(Q,{scrollTo:Y=>w.scrollTo(Y)}),()=>{w.off("select",ce),w.off("reInit",ce)}},[w]);const ae=q.useCallback(()=>w==null?void 0:w.scrollPrev(),[w]),ye=q.useCallback(()=>w==null?void 0:w.scrollNext(),[w]),Ge=N.length>1,Qe=f&&Ge&&N.length<=12;return n.jsxs("div",{className:v,children:[d==="header"&&Ge&&n.jsxs("div",{className:"mb-3 flex justify-end gap-2",children:[n.jsx(Gh,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx(Gh,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]}),n.jsxs("div",{className:"relative",children:[n.jsx("div",{ref:ce=>{K.current=ce,z(ce)},className:"slider-viewport",role:"group","aria-roledescription":"캐러셀","aria-label":i,tabIndex:0,onKeyDown:ce=>{ce.key==="ArrowLeft"&&(ce.preventDefault(),ae()),ce.key==="ArrowRight"&&(ce.preventDefault(),ye())},children:n.jsx("div",{className:"slider-track",style:{gap:`${h}rem`},id:G,children:c})}),d==="overlay"&&Ge&&n.jsxs(n.Fragment,{children:[n.jsx($h,{dir:"prev",onClick:ae,disabled:!u&&!X,label:i}),n.jsx($h,{dir:"next",onClick:ye,disabled:!u&&!P,label:i})]})]}),Ge&&n.jsxs("div",{className:"mt-4 flex items-center justify-center gap-3",children:[Qe&&n.jsx("ul",{className:"flex items-center gap-1.5",children:N.map((ce,k)=>n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>ee(k),"aria-label":`${k+1}번째로`,"aria-current":k===D,"aria-controls":G,className:`h-1.5 rounded-full transition-all ${k===D?"w-6 bg-current opacity-70":"w-1.5 bg-current opacity-20 hover:opacity-45"}`})},k))}),n.jsxs("span",{className:"text-current/55 text-[length:var(--fs-xs)] tabular-nums",children:[D+1," / ",N.length]})]})]})}function Mp({basis:i,children:c}){return n.jsx("div",{className:`min-w-0 shrink-0 grow-0 ${i}`,"aria-roledescription":"슬라이드",children:c})}function Gh({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:"tap border-line flex items-center justify-center rounded-full border transition-opacity hover:opacity-70 disabled:opacity-25",children:n.jsx(f,{className:"size-5"})})}function $h({dir:i,onClick:c,disabled:o,label:u}){const f=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":`${u} ${i==="prev"?"이전":"다음"}`,className:`tap absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:opacity-0 sm:flex ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(f,{className:"size-5"})})}function bu({children:i,className:c="",as:o="div"}){return n.jsx(o,{className:`panel ${c}`,children:i})}function vu({children:i}){return n.jsx("span",{className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]",children:i})}function kp({label:i,value:c,note:o}){return n.jsxs("div",{className:"divide-line flex flex-col gap-1 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6",children:[n.jsx("dt",{className:"text-muted shrink-0 text-[length:var(--fs-sm)] font-medium sm:w-40",children:i}),n.jsxs("dd",{className:"flex-1 text-[length:var(--fs-sm)] font-semibold sm:text-right",children:[c,o&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:o})]})]})}function Qh({href:i,children:c,variant:o="solid",external:u,icon:f}){const d=o==="solid";return n.jsxs("a",{href:i,...u?{target:"_blank",rel:"noopener noreferrer"}:{},className:`tap inline-flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-semibold transition-opacity hover:opacity-85 ${d?"":"border-line tpl-border border bg-current/5"}`,style:d?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"}:void 0,children:[f,n.jsx("span",{children:c}),u&&n.jsx(Sn,{className:"size-4 shrink-0"})]})}function ay(){const i=le(),{narrative:c,place:o}=i,u=U0(i,"intro"),f=u.length>0?u:c.about;if(f.length===0)return null;const d=lu(i).find(p=>!p.isPrimary),h=c.heroSubline??`${o.name} 소개`;return n.jsx(vt,{id:"about",bare:!0,title:h,children:n.jsxs("div",{className:"grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14",children:[d&&n.jsxs("figure",{className:"lg:col-span-7",children:[n.jsx("div",{className:"tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2",children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"size-full object-cover"})}),d.caption&&n.jsx("figcaption",{className:"text-muted mt-2 text-[length:var(--fs-xs)]",children:d.caption})]}),n.jsxs("div",{className:d?"lg:col-span-5":"lg:col-span-12",children:[n.jsxs("h2",{id:"about-heading",className:"h2 mb-6 flex items-center gap-2.5",children:[n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0 bg-current opacity-25"}),n.jsx("span",{className:"min-w-0",children:h})]}),n.jsx("div",{className:"measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]",children:f.map((p,g)=>n.jsx("p",{children:p},g))})]})]})})}const Xh=new Set(["체크인 시간","체크아웃 시간","취소·환불 규정","취사 가능","반려동물 동반","흡연 가능","인원 추가 요금"]);function ny(){const i=le(),c=dc(i),o=i.facts.filter(d=>d.scope==="place"&&!c.some(h=>h.label===d.label)).length;if(c.length===0)return null;const u=c.filter(d=>Xh.has(d.label)),f=c.filter(d=>!Xh.has(d.label));return n.jsx(vt,{id:"info",tone:"alt",title:"이용안내 및 예약",lead:"방문 전 확인이 필요한 운영 규정과 시설 안내입니다.",footnote:n.jsxs("span",{className:"flex flex-col gap-1 sm:flex-row sm:justify-between",children:[n.jsx("span",{children:o>0?`확인 중인 항목 ${o}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`:"모든 항목이 사업자 확인을 거쳤습니다."}),n.jsxs("span",{className:"font-medium",children:[nu(i.site.updatedAt)," 기준"]})]}),children:n.jsxs("div",{className:"space-y-8",children:[u.length>0&&n.jsx(Vh,{title:"예약 전 확인",rows:u,emphasis:!0}),f.length>0&&n.jsx(Vh,{title:"시설 · 편의",rows:f}),n.jsx(sy,{})]})})}function Vh({title:i,rows:c,emphasis:o}){return n.jsxs("section",{children:[n.jsxs("h3",{className:"border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[o&&n.jsx("i",{"aria-hidden":!0,className:"h-[1em] w-[3px] shrink-0",style:{backgroundColor:"var(--color-accent)"}}),n.jsx("span",{children:i})]}),n.jsx("dl",{className:"divide-line divide-y",children:c.map(u=>n.jsxs("div",{className:"flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6",children:[n.jsx("dt",{className:"text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0",children:u.label}),n.jsxs("dd",{className:"measure text-[length:var(--fs-sm)] font-semibold",children:[u.value,u.note&&n.jsx("span",{className:"text-muted mt-0.5 block font-normal",children:u.note})]})]},u.label))})]})}function sy(){const i=le(),c=Ze(i),o=i.place.phone;return!o&&c.length===0?null:n.jsxs("div",{className:"border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("p",{className:"text-[length:var(--fs-sm)] font-semibold",children:[i.place.name," 예약은 아래로 받습니다."]}),n.jsxs("div",{className:"flex flex-wrap gap-2",children:[o&&n.jsxs("a",{href:`tel:${o}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:o})]}),c.map(u=>n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)},u.url))]})]})}function iy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:n.jsx("ul",{className:"mt-2 flex flex-col gap-14 lg:gap-20",children:o.map((f,d)=>n.jsxs("li",{className:"grid items-center gap-7 lg:grid-cols-12 lg:gap-12",children:[n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-2":""}`,children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 rounded-lg overflow-hidden",children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),f.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:f.images.slice(1,4).map(h=>n.jsx("li",{className:"relative aspect-16/10 rounded-md overflow-hidden",children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]}),n.jsxs("div",{className:`lg:col-span-6 ${d%2===1?"lg:order-1":""}`,children:[n.jsx("h3",{className:"h2",children:f.name}),f.intro&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-body)] leading-relaxed opacity-80",children:f.intro}),f.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:h.label}),n.jsx("span",{className:"font-medium",children:h.value})]})},h.label))}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,f.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),f.rows.length>0&&n.jsxs("details",{className:"group border-line mt-4 border-t pt-2",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[f.name," 구성 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right font-semibold",children:h.value})]},h.label))})]})]})]},f.unitId))})})}function cy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0],[f,d]=q.useState(0);return o.length===0?null:n.jsxs(vt,{id:"units",title:Ye(i,c.path,`${c.label} 안내`),children:[n.jsx("div",{className:"border-line mb-6 flex gap-1 border-b",role:"tablist","aria-label":c.label,children:o.map((h,p)=>n.jsx("button",{type:"button",role:"tab","aria-selected":p===f,"aria-controls":`unit-panel-${h.slug}`,onClick:()=>d(p),className:`tap -mb-px border-b-2 px-5 text-[length:var(--fs-sm)] font-bold transition-colors ${p===f?"opacity-100":"border-transparent opacity-50 hover:opacity-80"}`,style:p===f?{borderColor:"var(--color-brand)"}:void 0,children:h.name},h.unitId))}),o.map((h,p)=>n.jsxs("div",{id:`unit-panel-${h.slug}`,role:"tabpanel",hidden:p!==f,className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"lg:col-span-7",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden rounded-lg",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),h.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-3 gap-2",children:h.images.slice(1,4).map(g=>n.jsx("li",{className:"relative aspect-16/10 overflow-hidden rounded-md",children:n.jsx("img",{src:g.url,alt:g.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},g.mediaId))})]}),n.jsxs("div",{className:"lg:col-span-5",children:[n.jsx("h3",{className:"h3",children:h.name}),h.intro&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"mt-4 flex flex-wrap items-center gap-1.5",children:h.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),h.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-4 divide-y border-t",children:h.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))}),n.jsxs("div",{className:"mt-5 flex gap-2",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u,h.name)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap border-line tpl-border flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]})]})]},h.unitId))]})}function ry(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];if(o.length===0)return null;const f=o.length===1?"grid-cols-1 max-w-2xl mx-auto":o.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",d=Kt(i,"info")?[]:dc(i);return n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"border-line paper w-full border-b",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"calc(var(--section-space) * 1.4)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(iu,{id:"units",title:Ye(i,c.path,`${c.label} 안내`)}),n.jsx("ul",{className:`grid gap-x-8 gap-y-14 lg:gap-x-12 ${f}`,children:o.map(h=>n.jsxs("li",{className:"flex min-w-0 flex-col",children:[h.images[0]&&n.jsx("div",{className:"relative aspect-4/3 w-full overflow-hidden",children:n.jsx("img",{src:h.images[0].url,alt:h.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})}),n.jsx("h3",{className:"serif mt-6",style:{fontSize:"var(--fs-h3)",fontWeight:300,letterSpacing:"0.04em"},children:h.name}),h.intro&&n.jsx("p",{className:"text-muted mt-3 line-clamp-2 text-[length:var(--fs-sm)] font-light leading-loose",children:h.intro}),h.chips.length>0&&n.jsx("ul",{className:"text-muted mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)] font-light",children:h.chips.map(p=>n.jsxs("li",{children:[p.label," ",p.value]},p.label))}),(h.rows.length>0||h.images.length>1)&&n.jsxs("details",{className:"border-line mt-5 border-t",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center text-[length:var(--fs-sm)] font-light opacity-70 marker:content-none [&::-webkit-details-marker]:hidden",children:"자세히 보기"}),n.jsxs("div",{className:"space-y-6 pb-2",children:[h.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:h.rows.map(p=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:p.label}),n.jsx("dd",{className:"text-right",children:p.value})]},p.label))}),h.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-3",children:h.images.slice(1).map(p=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover object-center"})},p.mediaId))})]})]})]},h.unitId))}),d.length>0&&n.jsx("dl",{className:"divide-line border-line mx-auto mt-16 max-w-2xl divide-y border-t",children:d.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-3 text-[length:var(--fs-sm)] font-light",children:[n.jsx("dt",{className:"text-muted shrink-0",children:h.label}),n.jsx("dd",{className:"text-right",children:h.value})]},h.label))}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-14 flex flex-wrap items-center justify-center gap-4",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-opacity hover:opacity-70",children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tabular-nums tracking-[0.08em] transition-opacity hover:opacity-70",children:i.place.phone})]})]})})}function oy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsxs("section",{id:"units","data-oasi":!0,"aria-labelledby":"units-heading",className:"pt-[var(--oasi-gap)]",children:[n.jsx(cu,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-20 lg:space-y-28",children:o.map(f=>n.jsxs("li",{children:[f.images[0]&&n.jsxs("figure",{className:"relative mx-auto w-full max-w-[34rem]",children:[n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"block h-[clamp(15rem,24vw,21rem)] w-full object-cover"}),n.jsx("figcaption",{className:"text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block",style:{writingMode:"vertical-rl"},children:f.name})]}),n.jsx("h3",{className:"mt-8 text-[length:var(--fs-sm)] font-normal tracking-[0.08em]",children:f.name}),f.chips.length>0&&n.jsx("p",{className:"text-muted mt-2 text-[length:var(--fs-xs)] leading-[1.9]",children:f.chips.map(d=>`${d.label} ${d.value}`).join(" · ")}),f.intro&&n.jsx("p",{className:"measure mt-4 text-[length:var(--fs-xs)] leading-[1.9]",children:f.intro}),f.rows.length>0&&n.jsx("dl",{className:"divide-line border-line mt-6 divide-y border-t text-[length:var(--fs-xs)]",children:f.rows.map(d=>n.jsxs("div",{className:"flex items-baseline justify-between gap-6 py-2.5",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right",children:d.value})]},d.label))}),f.images.length>1&&n.jsx("ul",{className:"mx-auto mt-6 grid w-full max-w-[34rem] grid-cols-2 gap-2",children:f.images.slice(1).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",className:"block aspect-[4/3] w-full object-cover"})},d.mediaId))}),(u||i.place.phone)&&n.jsxs("p",{className:"mt-6 flex flex-wrap items-center gap-x-8 text-[length:var(--fs-sm)]",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(u)}),i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:"tap text-muted inline-flex items-center underline underline-offset-[6px]",children:i.place.phone})]})]},f.unitId))})]})}function uy(){const i=le(),c=ft(i),o=fl(i);return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading",className:"w-full",style:{paddingBlock:"var(--section-space)",backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ru,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"space-y-16 sm:space-y-24",children:o.map(u=>n.jsxs("li",{className:"grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-10",children:[n.jsxs("div",{className:"min-w-0 lg:col-span-7",children:[u.images[0]&&n.jsx("img",{src:u.images[0].url,alt:u.images[0].alt,loading:"lazy",decoding:"async",width:u.images[0].width,height:u.images[0].height,className:"block h-auto w-full"}),u.images.length>1&&n.jsx("ul",{className:"mt-2 grid grid-cols-2 gap-2",children:u.images.slice(1).map(f=>n.jsx("li",{children:n.jsx("img",{src:f.url,alt:f.alt,loading:"lazy",decoding:"async",width:f.width,height:f.height,className:"block h-auto w-full"})},f.mediaId))})]}),n.jsxs("div",{className:"min-w-0 lg:col-span-5",children:[n.jsx("h3",{className:"serif min-w-0 break-keep font-extrabold",style:{fontSize:"var(--fs-h3)",letterSpacing:"0.02em",lineHeight:1.35},children:u.name}),u.chips.length>0&&n.jsx("dl",{className:"mt-4 space-y-1.5",children:u.chips.map(f=>n.jsxs("div",{className:"flex gap-3 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted w-20 shrink-0",children:f.label}),n.jsx("dd",{className:"min-w-0 font-medium",children:f.value})]},f.label))}),u.intro&&n.jsx("p",{className:"text-muted measure mt-5 text-[length:var(--fs-sm)]",style:{lineHeight:1.8},children:u.intro})]})]},u.unitId))})]})})}function fy(){const i=le(),c=ft(i),o=fl(i),u=Ze(i)[0];return o.length===0?null:n.jsx("section",{id:"units","aria-labelledby":"units-heading","data-pastel":"own",className:"w-full",style:{backgroundColor:"var(--tpl-surface, #fff)",paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsx(ou,{id:"units",title:Ye(i,c.path,`${c.label} ${o.length}개 안내`)}),n.jsx("ul",{className:"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:gap-8",children:o.map((f,d)=>n.jsxs("li",{className:"flex flex-col overflow-hidden p-4 sm:p-5",style:{backgroundColor:d%2===0?"var(--pastel-1)":"var(--pastel-2)",borderRadius:"calc(var(--tpl-radius, 0.75rem) * 1.35)"},children:[f.images[0]&&n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",style:{borderRadius:"var(--tpl-radius, 0.75rem)"},children:n.jsx("img",{src:f.images[0].url,alt:f.images[0].alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 px-1 pt-4",children:[n.jsx("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:n.jsx("h3",{className:"h3",style:{fontWeight:800},children:f.name})}),f.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:f.chips.map(h=>n.jsxs("li",{className:"inline-flex items-center gap-1 rounded-full px-3 py-1 text-[length:var(--fs-xs)]",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 70%, transparent)"},children:[n.jsx("span",{className:"opacity-60",children:h.label}),n.jsx("span",{className:"font-bold",children:h.value})]},h.label))}),f.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:f.intro})]}),(u||i.place.phone)&&n.jsxs("div",{className:"mt-4 flex gap-2 px-1",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex flex-1 items-center justify-center rounded-full text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--pastel-ink)",color:"var(--tpl-bg, #fff)"},children:ht(u)}),i.place.phone&&n.jsxs("a",{href:`tel:${i.place.phone}`,className:"tap flex items-center justify-center gap-1.5 rounded-full px-5 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 75%, transparent)"},children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:"전화"})]})]}),(f.rows.length>0||f.images.length>1)&&n.jsxs("details",{className:"group mt-3 px-1",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsxs("span",{children:[f.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsxs("div",{className:"space-y-4 pb-1",children:[f.rows.length>0&&n.jsx("dl",{className:"divide-line divide-y",children:f.rows.map(h=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"shrink-0 opacity-60",children:h.label}),n.jsx("dd",{className:"text-right font-bold",children:h.value})]},h.label))}),f.images.length>1&&n.jsx("ul",{className:"grid grid-cols-2 gap-2",children:f.images.slice(1).map(h=>n.jsx("li",{className:"relative aspect-4/3 overflow-hidden",style:{borderRadius:"calc(var(--tpl-radius, 0.75rem) * 0.7)"},children:n.jsx("img",{src:h.url,alt:h.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})},h.mediaId))})]})]})]},f.unitId))})]})})}function dy(){const i=le(),c=fl(i),o=ft(i),u=Ze(i)[0];return c.length===0?null:n.jsxs("section",{id:"units","aria-labelledby":"units-heading",className:"border-line w-full border-b",style:{backgroundColor:"var(--tpl-surface)",backgroundImage:Os,paddingBlock:"var(--section-space)"},children:[n.jsx("div",{className:"shell",children:n.jsx(au,{id:"units",title:Ye(i,o.path,`${o.label} ${c.length}개 안내`)})}),c.map((f,d)=>n.jsx(my,{unit:f,no:String(d+1).padStart(2,"0"),dark:d%2===1,booking:u,phone:i.place.phone},f.unitId))]})}function my({unit:i,no:c,dark:o,booking:u,phone:f}){return n.jsx("article",{className:"w-full",style:o?{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os}:void 0,children:n.jsxs("div",{className:"shell grid grid-cols-12 gap-y-6 py-10 xl:gap-x-10 xl:py-16",children:[n.jsxs("div",{className:"col-span-12 xl:col-span-5 xl:pr-8",children:[n.jsxs("div",{className:"relative",children:[n.jsx("span",{"aria-hidden":!0,className:"serif pointer-events-none absolute -top-1 left-0 select-none leading-none tabular-nums",style:{fontSize:"clamp(3rem, 8vw, 6rem)",fontWeight:800,opacity:.1},children:c}),n.jsx("h3",{className:"serif relative pt-7 xl:pt-12",style:{fontSize:"clamp(1.5rem, 3vw, 2.25rem)",fontWeight:800,lineHeight:1.1,letterSpacing:"-0.02em"},children:i.name})]}),i.intro&&n.jsx("p",{className:"measure mt-4 font-serif text-[length:var(--fs-body)] leading-relaxed opacity-85",children:i.intro}),i.rows.length>0&&n.jsx("dl",{className:"border-line mt-6 border-t",children:i.rows.map(d=>n.jsxs("div",{className:"border-line flex items-baseline justify-between gap-6 border-b py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:d.label}),n.jsx("dd",{className:"text-right font-semibold",children:d.value})]},d.label))}),(u||f)&&n.jsxs("div",{className:"mt-6 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold",children:[u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center underline underline-offset-4",children:ht(u)}),f&&n.jsx("a",{href:`tel:${f}`,className:"tap inline-flex items-center underline underline-offset-4",children:f})]})]}),n.jsxs("div",{className:"col-span-12 xl:col-span-7 xl:-ml-10",children:[i.images[0]&&n.jsx("figure",{className:"w-full xl:w-[64%]",children:n.jsx("img",{src:i.images[0].url,alt:i.images[0].alt,loading:"lazy",decoding:"async",width:i.images[0].width,height:i.images[0].height,className:"w-full object-cover",style:{aspectRatio:"4 / 5"}})}),i.images[1]&&n.jsx("figure",{className:"mt-3 w-full xl:-mt-24 xl:ml-auto xl:w-[50%]",children:n.jsx("img",{src:i.images[1].url,alt:i.images[1].alt,loading:"lazy",decoding:"async",width:i.images[1].width,height:i.images[1].height,className:"w-full object-cover",style:{aspectRatio:"4 / 3"}})})]}),i.images.length>2&&n.jsx("ul",{className:"col-span-12 grid grid-cols-3 gap-2 xl:grid-cols-6",children:i.images.slice(2).map(d=>n.jsx("li",{children:n.jsx("img",{src:d.url,alt:d.alt,loading:"lazy",decoding:"async",width:d.width,height:d.height,className:"w-full object-cover",style:{aspectRatio:"3 / 2"}})},d.mediaId))})]})})}function Lo(){var h;const i=le(),c=ft(i),o=hc();if(o==="reservation")return n.jsx(ry,{});if(o==="oasi")return n.jsx(oy,{});if(o==="studio")return n.jsx(uy,{});if(o==="pastel")return n.jsx(fy,{});if(o==="editorial")return n.jsx(dy,{});const u=(h=i.theme.sections.find(p=>p.id===c.path))==null?void 0:h.variantId;if(u==="rooms.bands")return n.jsx(iy,{});if(u==="rooms.tabs")return n.jsx(cy,{});const f=fl(i);if(f.length===0)return null;const d=f.length===1?"grid-cols-1":f.length===2?"grid-cols-1 sm:grid-cols-2":"grid-cols-1 sm:grid-cols-2 xl:grid-cols-3";return n.jsx(vt,{id:"units",title:Ye(i,c.path,`${c.label} ${f.length}개 안내`),children:n.jsx("ul",{className:`grid gap-5 lg:gap-7 ${d}`,children:f.map(p=>n.jsxs(bu,{as:"li",className:"flex flex-col overflow-hidden",children:[n.jsxs("div",{className:"flex flex-1 flex-col",children:[p.images.length>0&&n.jsx(hy,{images:p.images,name:p.name}),n.jsxs("div",{className:"flex flex-1 flex-col gap-3 p-5",children:[n.jsx("h3",{className:"h3",children:p.name}),p.chips.length>0&&n.jsx("ul",{className:"flex flex-wrap items-center gap-1.5",children:p.chips.map(g=>n.jsx("li",{children:n.jsxs(vu,{children:[n.jsx("span",{className:"text-muted",children:g.label}),n.jsx("span",{className:"font-medium",children:g.value})]})},g.label))}),p.intro&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:p.intro})]})]}),p.rows.length>0&&n.jsxs("details",{className:"group border-line border-t",children:[n.jsxs("summary",{className:"tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold",children:[n.jsxs("span",{children:[p.name," 자세히"]}),n.jsx(fc,{className:"size-4 shrink-0 transition-transform group-open:rotate-180"})]}),n.jsx("div",{className:"space-y-5 px-5 pb-5",children:p.rows.length>0&&n.jsx("dl",{className:"divide-line border-line divide-y border-t",children:p.rows.map(g=>n.jsxs("div",{className:"flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]",children:[n.jsx("dt",{className:"text-muted shrink-0",children:g.label}),n.jsx("dd",{className:"text-right font-semibold",children:g.value})]},g.label))})})]})]},p.unitId))})})}function hy({images:i,name:c}){const o=q.useRef(null),[u,f]=q.useState(0),d=q.useCallback(()=>{const p=o.current;!p||p.clientWidth===0||f(Math.round(p.scrollLeft/p.clientWidth))},[]),h=q.useCallback(p=>{const g=o.current;if(!g)return;const x=window.matchMedia("(prefers-reduced-motion: reduce)").matches;g.scrollBy({left:p*g.clientWidth,behavior:x?"auto":"smooth"})},[]);return n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:o,onScroll:d,"aria-label":`${c} 사진`,className:"flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",children:i.map(p=>n.jsx("li",{className:"w-full shrink-0 snap-center",children:n.jsx("div",{className:"relative aspect-16/10 overflow-hidden",children:n.jsx("img",{src:p.url,alt:p.alt,loading:"lazy",decoding:"async",className:"size-full object-cover"})})},p.mediaId))}),i.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(Zh,{dir:"prev",show:u>0,onClick:()=>h(-1)}),n.jsx(Zh,{dir:"next",show:u<i.length-1,onClick:()=>h(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white",children:[Math.min(u+1,i.length)," / ",i.length]})]})]})}function Zh({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-4"})})}function py(){const i=le(),c=Ze(i),o=B0(i),u=i.place.phone;return c.length===0&&o.length===0&&!u?null:n.jsx(vt,{id:"booking",tone:"alt",title:Ye(i,"booking","예약 안내"),lead:`${i.place.name} 예약은 아래 경로로 받습니다.`,children:n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-10",children:[o.length>0&&n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5 lg:col-span-7",children:o.map(f=>n.jsx(kp,{label:f.label,value:f.value},f.label))}),n.jsx("div",{className:o.length>0?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:o.length>0?"flex flex-col gap-2.5 sm:flex-row sm:flex-wrap lg:flex-col":"flex flex-col items-start gap-2.5 sm:flex-row sm:flex-wrap",children:[u&&n.jsxs(Qh,{href:`tel:${u}`,icon:n.jsx(At,{className:"size-4"}),children:["전화 예약 ",u]}),c.map(f=>n.jsx(Qh,{href:f.url,variant:"outline",external:!0,children:Sp(f)},f.url))]})})]})})}const Kh=["일","월","화","수","목","금","토"],Jh=2;function Wh(i){return`${i.getFullYear()}-${String(i.getMonth()+1).padStart(2,"0")}-${String(i.getDate()).padStart(2,"0")}`}function xy(i,c,o){const u=new Date(i,c,1),f=new Date(i,c+1,0).getDate(),d=Wh(o),h=Array.from({length:u.getDay()},()=>null);for(let p=1;p<=f;p+=1){const g=new Date(i,c,p),x=Wh(g);h.push({iso:x,day:p,weekday:g.getDay(),isWeekend:g.getDay()===0||g.getDay()===6,past:x<d})}return h}function gy(i){const c=/(\d{1,2})\s*:\s*(\d{2})/.exec(i??"");if(!c)return[];const o=Number(c[1]),u=c[2];return Number.isFinite(o)?Array.from({length:5},(f,d)=>o+d).filter(f=>f<=23).map(f=>`${String(f).padStart(2,"0")}:${u}`):[]}function by(i){return eu(i.units).map(c=>{var u;const o=f=>{var h;const d=Number((h=Tt(c.facts,f))==null?void 0:h.replace(/[^0-9]/g,""));return Number.isFinite(d)&&d>0?d:void 0};return{unitId:c.unitId,name:c.name,weekdayPrice:(u=yp(c))==null?void 0:u.price,weekendPrice:o("weekend_price"),maxCapacity:o("max_capacity")}})}function vy(){var se;const i=le(),c=q.useMemo(()=>by(i),[i]),o=q.useMemo(()=>{var Q;return((Q=Bs(i.facts).find(ae=>ae.key==="check_in_time"))==null?void 0:Q.value)??void 0},[i]),u=q.useMemo(()=>gy(o),[o]),[f,d]=q.useState(null),[h,p]=q.useState(0);q.useEffect(()=>d(new Date),[]);const g=q.useMemo(()=>f?new Date(f.getFullYear(),f.getMonth()+h,1):null,[f,h]),x=q.useMemo(()=>f&&g?xy(g.getFullYear(),g.getMonth(),f):[],[f,g]),[v,y]=q.useState(null),[z,w]=q.useState(null),[N,T]=q.useState(((se=c[0])==null?void 0:se.unitId)??null),[D,U]=q.useState(2),[X,Z]=q.useState(!1),P=x.find(Q=>Q!==null&&Q.iso===v)??null,$=c.find(Q=>Q.unitId===N)??null,K=($==null?void 0:$.maxCapacity)??8,G=P&&$?P.isWeekend?$.weekendPrice??$.weekdayPrice:$.weekdayPrice:void 0,ee=!!(v&&$&&(u.length===0||z));return q.useEffect(()=>{U(Q=>Math.min(Q,($==null?void 0:$.maxCapacity)??8))},[$]),c.length===0?null:n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("div",{className:"border-b border-black/8 px-4 py-3 sm:px-5",children:n.jsxs("p",{className:"flex items-center gap-2 text-xs font-bold",children:[n.jsx(Nb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"날짜 · 시간 선택"})]})}),f===null||g===null?n.jsx("p",{className:"px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5",children:"날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 확인해 주세요."}):X?n.jsx(yy,{payload:i,onReset:()=>Z(!1),summary:[P?`${g.getMonth()+1}월 ${P.day}일(${Kh[P.weekday]})`:null,z?`도착 ${z}`:null,($==null?void 0:$.name)??null,`${D}명`].filter(Q=>!!Q).join(" · ")}):n.jsxs("div",{className:"space-y-5 p-4 sm:p-5",children:[n.jsxs("div",{children:[n.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"날짜"}),n.jsxs("div",{className:"flex items-center gap-1",children:[n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.max(0,Q-1)),disabled:h===0,"aria-label":"이전 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(oa,{className:"size-3.5"})}),n.jsxs("span",{className:"w-24 text-center text-xs font-bold",children:[g.getFullYear(),"년 ",g.getMonth()+1,"월"]}),n.jsx("button",{type:"button",onClick:()=>p(Q=>Math.min(Jh,Q+1)),disabled:h>=Jh,"aria-label":"다음 달",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30",children:n.jsx(ua,{className:"size-3.5"})})]})]}),n.jsx("div",{className:"grid grid-cols-7 gap-1 border-b border-black/8 pb-1.5",children:Kh.map((Q,ae)=>n.jsx("span",{className:"text-center text-[10px] font-semibold",style:{opacity:ae===0||ae===6?.75:.45},children:Q},Q))}),n.jsx("div",{className:"mt-1.5 grid grid-cols-7 gap-1",children:x.map((Q,ae)=>Q===null?n.jsx("span",{"aria-hidden":!0},`pad-${ae}`):n.jsx("button",{type:"button",disabled:Q.past,onClick:()=>y(Q.iso),"aria-pressed":Q.iso===v,"aria-label":`${g.getMonth()+1}월 ${Q.day}일`,className:"flex h-9 items-center justify-center rounded-lg border text-xs transition-colors disabled:cursor-not-allowed",style:{borderColor:Q.iso===v?"var(--color-brand)":"transparent",backgroundColor:Q.iso===v?"var(--color-brand)":"var(--color-surface-alt)",color:Q.iso===v?"#fff":void 0,opacity:Q.past?.25:1,fontWeight:Q.iso===v?700:400},children:Q.day},Q.iso))}),(P==null?void 0:P.isWeekend)&&n.jsx("p",{className:"mt-2 text-[11px] opacity-55",children:"주말 요금이 적용되는 날짜입니다."})]}),u.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"mb-2 flex items-center gap-1.5 text-[11px] font-semibold opacity-55",children:[n.jsx(Cb,{className:"size-3"}),n.jsxs("span",{children:["도착 예정 시간 (체크인 ",o," 이후)"]})]}),n.jsx("ul",{className:"flex flex-wrap gap-1.5",children:u.map(Q=>{const ae=Q===z;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>w(Q),"aria-pressed":ae,className:"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:ae?"var(--color-brand)":"var(--color-surface-alt)",color:ae?"#fff":void 0},children:Q})},Q)})})]}),n.jsxs("div",{children:[n.jsx("p",{className:"mb-2 text-[11px] font-semibold opacity-55",children:"객실"}),n.jsx("ul",{className:"grid gap-1.5 sm:grid-cols-2",children:c.map(Q=>{const ae=Q.unitId===N;return n.jsx("li",{children:n.jsxs("button",{type:"button",onClick:()=>T(Q.unitId),"aria-pressed":ae,className:"flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left text-xs transition-colors",style:{borderColor:ae?"var(--color-brand)":"rgba(0,0,0,0.10)",backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{className:"font-semibold",children:Q.name}),Q.maxCapacity&&n.jsxs("span",{className:"shrink-0 opacity-55",children:["최대 ",Q.maxCapacity,"명"]})]})},Q.unitId)})})]}),n.jsxs("div",{className:"flex items-center justify-between",children:[n.jsx("p",{className:"text-[11px] font-semibold opacity-55",children:"인원"}),n.jsxs("div",{className:"flex items-center gap-3",children:[n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.max(1,Q-1)),"aria-label":"인원 줄이기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Gb,{className:"size-3.5"})}),n.jsxs("span",{className:"w-10 text-center text-sm font-bold",children:[D,"명"]}),n.jsx("button",{type:"button",onClick:()=>U(Q=>Math.min(K,Q+1)),"aria-label":"인원 늘리기",className:"flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5",children:n.jsx(Kb,{className:"size-3.5"})})]})]}),n.jsxs("div",{className:"rounded-xl border border-black/8 p-3",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[n.jsx("span",{className:"text-xs opacity-60",children:P?`${g.getMonth()+1}월 ${P.day}일 · ${($==null?void 0:$.name)??""} · ${D}명`:"날짜를 골라 주세요"}),G!=null&&n.jsxs("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:[G.toLocaleString("ko-KR"),"원"]})]}),G!=null&&n.jsx("p",{className:"mt-1 text-[11px] opacity-50",children:"1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다."})]}),n.jsx("button",{type:"button",disabled:!ee,onClick:()=>Z(!0),className:"w-full rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40",style:{backgroundColor:"var(--color-brand)"},children:"예약 요청 확인하기"})]})]})}function yy({payload:i,summary:c,onReset:o}){const u=i.place.phone;return n.jsxs("div",{className:"space-y-4 p-4 sm:p-5",children:[n.jsxs("div",{className:"flex items-start gap-2.5",children:[n.jsx("span",{className:"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-white",style:{backgroundColor:"var(--color-brand)"},children:n.jsx(dp,{className:"size-4"})}),n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"text-sm font-bold",children:"예약 내용 확인"}),n.jsx("p",{className:"mt-0.5 text-xs leading-relaxed opacity-70",children:c})]})]}),n.jsxs("div",{className:"flex flex-col gap-2 sm:flex-row",children:[u&&n.jsxs("a",{href:`tel:${u}`,className:"flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(At,{className:"size-4"}),n.jsxs("span",{children:["전화로 예약하기 ",u]})]}),n.jsxs("button",{type:"button",onClick:o,className:"flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",children:[n.jsx(Wb,{className:"size-3.5"}),n.jsx("span",{children:"다시 고르기"})]})]})]})}function Fh(){const i=le(),c=J0(i);if(!c)return null;const{offers:o,notices:u,links:f,contacts:d,phone:h}=c;return n.jsx("section",{id:"booking","aria-labelledby":"booking-heading",className:"w-full border-b border-black/8 py-16 sm:py-24",style:{backgroundColor:"var(--color-surface-alt)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("p",{className:"mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50",children:[n.jsx(yb,{className:"size-4"}),n.jsx("span",{children:"Reservation"})]}),n.jsx("h2",{id:"booking-heading",className:"serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl",children:Ye(i,"booking","예약 안내")}),n.jsx("p",{className:"mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm",children:"빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만 안내합니다."}),n.jsxs("div",{className:"grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6",children:[o.length>0&&n.jsxs("div",{className:"overflow-hidden rounded-2xl border border-black/8 lg:col-span-3",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(bb,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"객실별 요금 · 인원"})]}),n.jsx("ul",{className:"divide-y divide-black/5",children:o.map(p=>n.jsxs("li",{className:"p-4 sm:p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1",children:[n.jsx("h3",{className:"serif text-base font-bold sm:text-lg",children:p.name}),p.baseRateText&&n.jsx("span",{className:"text-sm font-bold",style:{color:"var(--color-brand)"},children:p.baseRateText})]}),p.capacityText&&n.jsx("p",{className:"mt-1 text-xs opacity-60",children:p.capacityText}),p.rateRows.length>0&&n.jsx("dl",{className:"mt-3 flex flex-wrap gap-x-4 gap-y-1.5",children:p.rateRows.map(g=>n.jsxs("div",{className:"flex items-baseline gap-1.5 text-xs",children:[n.jsx("dt",{className:"opacity-50",children:g.label}),n.jsx("dd",{className:"font-semibold",children:g.value})]},g.label))}),n.jsx("a",{href:p.href,className:"mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70",children:n.jsxs("span",{children:[p.name," 사진 · 상세 보기"]})})]},p.unitId))})]}),n.jsxs("div",{className:"flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2",style:{backgroundColor:"var(--color-surface)"},children:[n.jsx("p",{className:"text-xs font-bold",children:"예약 창구"}),h&&n.jsxs("a",{href:`tel:${h}`,className:"flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)"},children:[n.jsx(At,{className:"size-4"}),n.jsxs("span",{children:["전화 예약 ",h]})]}),f.map(p=>n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5",style:{backgroundColor:"var(--color-surface-alt)"},children:[n.jsx("span",{children:Sp(p)}),n.jsx(Sn,{className:"size-3.5 shrink-0"})]},p.url)),d.length>0&&n.jsxs("div",{className:"mt-1 border-t border-black/8 pt-3",children:[n.jsx("p",{className:"mb-2 text-xs opacity-50",children:"문의"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:d.map(p=>n.jsx("li",{children:n.jsxs("a",{href:p.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5",children:[n.jsx("span",{children:ml(p)}),n.jsx(Sn,{className:"size-3"})]})},p.url))})]}),f.length===0&&n.jsx("p",{className:"text-xs leading-relaxed opacity-55",children:"온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요."})]})]}),n.jsx(vy,{}),u.length>0&&n.jsxs("div",{className:"mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6",style:{backgroundColor:"var(--color-surface)"},children:[n.jsxs("p",{className:"flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5",children:[n.jsx(Ib,{className:"size-3.5 opacity-50"}),n.jsx("span",{children:"예약 전 확인"})]}),n.jsx("dl",{className:"grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0",children:u.map(p=>n.jsxs("div",{className:"flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5",children:[n.jsx("dt",{className:"shrink-0 text-xs opacity-55",children:p.label}),n.jsx("dd",{className:"text-xs font-semibold sm:max-w-[60%] sm:text-right",children:p.value})]},p.label))})]})]})})}function jy(){const i=le(),c=Y0(i);return c.length===0?null:n.jsx(vt,{id:"space",title:Ye(i,"space","공간 안내"),lead:"좌석과 이용 환경 안내입니다.",children:n.jsx("dl",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4",children:c.map(o=>n.jsxs(bu,{className:"p-4 sm:p-5",children:[n.jsx("dt",{className:"text-muted mb-1 text-[length:var(--fs-xs)] font-medium",children:o.label}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold",children:o.value})]},o.label))})})}function Ny(){const i=le(),{place:c}=i,o=wn(i);if(!c.phone&&!c.email&&o.length===0)return null;const u=[...c.phone?[{key:"phone",icon:At,label:"전화",value:c.phone,href:`tel:${c.phone}`}]:[],...c.email?[{key:"email",icon:mp,label:"이메일",value:c.email,href:`mailto:${c.email}`}]:[],...o.map(f=>({key:f.url,icon:Io,label:ml(f),value:"바로 가기",href:f.url,external:!0}))];return n.jsx(vt,{id:"inquiry",title:Ye(i,"inquiry","문의 안내"),lead:`궁금한 점은 아래로 연락 주시면 ${c.name}에서 직접 안내해 드립니다.`,children:n.jsx("ul",{className:"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 lg:gap-4",children:u.map(f=>{const d=f.icon,h="external"in f&&f.external;return n.jsx("li",{children:n.jsxs("a",{href:f.href,...h?{target:"_blank",rel:"noopener noreferrer"}:{},className:"panel flex h-full items-center justify-between gap-3 p-5 transition-opacity hover:opacity-80",children:[n.jsxs("span",{className:"flex min-w-0 items-center gap-3",children:[n.jsx(d,{className:"size-5 shrink-0 opacity-40"}),n.jsxs("span",{className:"min-w-0",children:[n.jsx("span",{className:"text-muted block text-[length:var(--fs-xs)] font-medium",children:f.label}),n.jsx("span",{className:"block break-all text-[length:var(--fs-sm)] font-semibold",children:f.value})]})]}),h&&n.jsx(Sn,{className:"size-4 shrink-0 opacity-40"})]})},f.key)})})})}function Sy(){const i=le(),c=$0(i);return c.length===0?null:n.jsx(vt,{id:"exhibition",tone:"alt",title:Ye(i,"exhibition","관람 안내"),lead:"방문 전 관람 조건을 확인해 주세요.",children:n.jsx("dl",{className:"panel divide-line divide-y overflow-hidden px-5",children:c.map(o=>n.jsx(kp,{label:o.label,value:o.value},o.label))})})}function zy(){const[i,c]=q.useState([]);return q.useEffect(()=>c(E0()),[]),i}const Ey=["봄","여름","가을","겨울"];function wy(){const i=le(),c=i.local.festivals,o=zy(),[u,f]=q.useState(null);if(c.length===0)return null;const d=Ey.filter(y=>c.some(z=>z.season===y)),h=c.filter(y=>{var z;return!((z=y.season)!=null&&z.trim())}),p=[...o].reverse().find(y=>d.includes(y)),g="전체",x=u??p??null,v=x===g;return n.jsxs(vt,{id:"festival",title:"계절별 축제",lead:`${i.place.addressLocality??"이 지역"}의 축제와 행사를 계절로 묶었습니다.`,aside:x&&!v?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:["지금은 ",x," 축제입니다"]}):void 0,footnote:n.jsx(n.Fragment,{children:"한국관광공사 TourAPI 기준. 일정은 주최 측 사정으로 바뀔 수 있습니다."}),children:[d.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"계절",children:[...d,g].map(y=>{const z=x===y;return n.jsx("button",{type:"button",role:"tab","aria-selected":z,onClick:()=>f(y),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:z?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:y},y)})}),n.jsxs("div",{className:"space-y-8",children:[d.map(y=>{const z=c.filter(w=>w.season===y);return n.jsxs("div",{hidden:!v&&x!==null&&x!==y,children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:y}),n.jsx(Ih,{items:z,label:`${y} 축제`,remount:x})]},y)}),h.length>0&&n.jsxs("div",{children:[n.jsx("h3",{className:"border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:"계절 없이 열리는 행사"}),n.jsx(Ih,{items:h,label:"계절 없이 열리는 행사"})]})]})]})}function Ih({items:i,label:c,remount:o}){return n.jsx(gu,{label:c,children:i.map(u=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(u.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[u.imageUrl?n.jsx("img",{src:u.imageUrl,alt:`${u.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:u.name}),n.jsx("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:u.month})]}),n.jsxs("span",{className:"flex min-w-0 flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:u.name}),u.period&&n.jsx("span",{className:"text-[length:var(--fs-xs)] font-medium opacity-80",children:u.period}),u.location&&n.jsxs("span",{className:"text-muted flex items-start gap-1 text-[length:var(--fs-xs)]",children:[n.jsx(Ls,{className:"mt-0.5 size-3 shrink-0 opacity-60"}),n.jsx("span",{children:u.location})]}),u.description&&n.jsx("span",{className:"text-muted line-clamp-3 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed",children:u.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},u.name))},o??"all")}const As=80,Ph=[{id:"all",label:"전체",test:()=>!0},{id:"w5",label:"걸어서 5분 이내",test:i=>i<=5*As},{id:"w10",label:"걸어서 10분 이내",test:i=>i<=10*As},{id:"far",label:"걸어서 10분 이상",test:i=>i>10*As}];function Bo(i){if(Number.isFinite(i))return`도보 약 ${Math.max(1,Math.round(i/As))}분`}function Cs(i){const c=/^([\d.]+)\s*(km|m)$/i.exec((i??"").trim());return c?Number(c[1])*(c[2].toLowerCase()==="km"?1e3:1):1/0}function _y(){const i=le(),{local:c}=i,o=c.restaurants.length>0||c.attractions.length>0,[u,f]=q.useState("all");if(!o)return null;const d=[...c.restaurants,...c.attractions],h=v=>d.filter(y=>v.test(Cs(y.distanceText))).length,p=Ph.filter((v,y)=>y===0||h(v)>0&&h(v)<d.length),g=p.find(v=>v.id===u)??Ph[0],x=v=>g.test(Cs(v.distanceText));return n.jsxs(vt,{id:"guide",tone:"alt",title:"주변 안내",lead:`${i.place.addressLocality??"주변"} 지역의 맛집 · 명소 안내입니다.`,aside:c.syncedAt?n.jsxs("p",{className:"text-muted text-[length:var(--fs-xs)]",children:[nu(c.syncedAt)," 갱신"]}):void 0,footnote:n.jsxs(n.Fragment,{children:["도보 시간은 숙소에서 잰 직선거리를 분속 ",As,"m 로 환산한 값입니다. 실제로 걷는 길은 이보다 깁니다."]}),children:[p.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"거리",children:p.map(v=>{const y=v.id===u,z=h(v);return n.jsxs("button",{type:"button",role:"tab","aria-selected":y,onClick:()=>f(v.id),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:y?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[v.label," ",n.jsx("span",{className:"tabular-nums opacity-70",children:z})]},v.id)})}),n.jsxs("div",{className:"space-y-10",children:[c.restaurants.length>0&&n.jsx(ep,{title:"주변 맛집",icon:e0,places:c.restaurants,within:x}),c.attractions.length>0&&n.jsx(ep,{title:"주변 명소",icon:Ls,places:c.attractions,within:x})]})]})}function Ty({icon:i,children:c}){return n.jsxs("h3",{className:"border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(i,{className:"size-4 opacity-50"}),n.jsx("span",{children:c})]})}function ep({title:i,icon:c,places:o,within:u}){const f=o.filter(u);return n.jsxs("div",{children:[n.jsx(Ty,{icon:c,children:i}),n.jsx(gu,{label:i,children:f.map(d=>n.jsx(Mp,{basis:"basis-[76%] sm:basis-1/3 lg:basis-1/4",children:n.jsxs("a",{href:Vo(d.searchQuery),target:"_blank",rel:"noopener noreferrer nofollow",className:"panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85",children:[n.jsxs("span",{className:"relative block aspect-4/3 overflow-hidden",children:[d.imageUrl?n.jsx("img",{src:d.imageUrl,alt:`${d.name} 사진`,loading:"lazy",decoding:"async",className:"size-full object-cover transition-transform duration-500 group-hover:scale-105"}):n.jsx("span",{className:"serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight",style:{backgroundColor:"color-mix(in oklab, currentColor 9%, transparent)"},"aria-hidden":!0,children:d.name}),d.distanceText&&n.jsxs("span",{className:"absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)",color:"var(--tpl-bg, #fff)"},children:[Bo(Cs(d.distanceText))??d.distanceText,n.jsx("span",{className:"ml-1 font-normal opacity-70",children:d.distanceText})]})]}),n.jsxs("span",{className:"flex flex-1 flex-col gap-1 p-3.5",children:[n.jsx("span",{className:"text-[length:var(--fs-sm)] font-bold",children:d.name}),d.description&&n.jsx("span",{className:"text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed",children:d.description}),n.jsxs("span",{className:"text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100",children:[n.jsx("span",{children:"검색으로 열기"}),n.jsx(Sn,{className:"size-3.5"})]})]})]})},d.name))},f.length),n.jsx("ul",{hidden:!0,children:o.filter(d=>!u(d)).map(d=>n.jsxs("li",{children:[n.jsx("a",{href:Vo(d.searchQuery),rel:"nofollow",children:d.name}),d.distanceText&&n.jsxs("span",{children:[" ",d.distanceText,Bo(Cs(d.distanceText))&&` · ${Bo(Cs(d.distanceText))}`]}),d.description&&n.jsx("p",{children:d.description})]},d.name))}),f.length===0&&n.jsx("p",{className:"text-muted text-[length:var(--fs-sm)]",children:"이 거리 안에는 없습니다."})]})}function Ay(i){return i===0?"맑음":i>=1&&i<=3?"구름많음":i>=50&&i<=69?"비":i>=70&&i<=79?"눈":"흐림"}function Cy({initial:i,regionCode:c,latitude:o,longitude:u}){const[f,d]=q.useState(i);return q.useEffect(()=>{if(!c||o==null||u==null)return;const h=new AbortController;async function p(){var x;try{const v=new URLSearchParams({region_code:c,latitude:String(o),longitude:String(u)}),y=await fetch(`/v1/local/weather?${v}`,{signal:h.signal});if(!y.ok)return;const z=await y.json();if(!((x=z==null?void 0:z.result)!=null&&x.success)||!z.weather)return;d(w=>({temperature:Number(z.weather.temperature),condition:Ay(Number(z.weather.weather_code)),note:w==null?void 0:w.note,observedAt:z.weather.observed_at,stale:!!z.stale}))}catch{}}p();const g=window.setInterval(()=>void p(),600*1e3);return()=>{h.abort(),window.clearInterval(g)}},[o,u,c]),f}function My(i){const c=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(i??"");if(!c)return;const[,,o,u,f,d]=c;return`${Number(o)}월 ${Number(u)}일 ${f}:${d} 관측`}function ky(){var p,g,x,v;const i=le(),c=Cy({initial:i.local.weather,regionCode:i.place.regionCode,latitude:i.place.latitude,longitude:i.place.longitude});if(!c)return null;const o=k0(c.condition),u=O0(c.temperature),f=((g=(p=i.local.weather)==null?void 0:p.notes)==null?void 0:g[o])??c.note,d=(v=(x=i.local.weather)==null?void 0:x.tempNotes)==null?void 0:v[u],h=My(c.observedAt);return n.jsx(vt,{id:"weather",tone:"alt",title:"오늘의 날씨",children:n.jsxs(bu,{className:"relative overflow-hidden",children:[n.jsx("div",{className:"w4-sky","data-mood":o,"aria-hidden":!0}),n.jsxs("div",{className:"relative flex flex-col gap-5 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-4 gap-y-1",children:[n.jsxs("p",{className:"flex items-baseline gap-2.5",children:[n.jsx("span",{className:"serif tabular-nums leading-none",style:{fontSize:"var(--fs-display)"},children:Math.round(c.temperature)}),n.jsx("span",{className:"text-[length:var(--fs-lead)] opacity-70",children:"°C"}),n.jsx("span",{className:"text-[length:var(--fs-lead)] font-semibold",children:c.condition})]}),h&&n.jsxs("span",{className:"text-muted text-[length:var(--fs-xs)] tabular-nums",children:[h,c.stale&&" · 최근 관측값"]})]}),f&&n.jsx("p",{className:"measure serif text-[length:var(--fs-lead)] leading-loose opacity-85",children:f}),d&&n.jsxs("p",{className:"measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-75",children:[n.jsx("span",{className:"border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold","aria-hidden":!0,children:u}),n.jsx("span",{children:d})]})]})]})})}function Oy(){const i=le(),c=lu(i),o=i.theme.sections.find(T=>T.id==="photos"),u=(o==null?void 0:o.variantId)??"photos.grid",[f,d]=q.useState(null),h=q.useRef(null),[p,g]=q.useState(0),[x,v]=q.useState({prev:!1,next:!0}),y=q.useCallback(()=>{const T=h.current;if(!T)return;const D=T.clientWidth,U=T.scrollWidth-D;g(D>0?Math.round(T.scrollLeft/D):0),v({prev:T.scrollLeft>8,next:T.scrollLeft<U-8})},[]);q.useEffect(()=>(y(),window.addEventListener("resize",y),()=>window.removeEventListener("resize",y)),[y]),xu({box:h,interval:pu,advance:()=>Cp(h.current,1)});const z=q.useCallback(T=>{const D=h.current;if(!D)return;const U=window.matchMedia("(prefers-reduced-motion: reduce)").matches;D.scrollBy({left:T*D.clientWidth,behavior:U?"auto":"smooth"})},[]),w=q.useCallback(()=>d(null),[]),N=q.useCallback(T=>d(D=>D===null?null:(D+T+c.length)%c.length),[c.length]);return q.useEffect(()=>{if(f===null)return;const T=U=>{U.key==="Escape"&&w(),U.key==="ArrowLeft"&&N(-1),U.key==="ArrowRight"&&N(1)},D=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",T),()=>{document.body.style.overflow=D,window.removeEventListener("keydown",T)}},[f,w,N]),c.length===0?null:n.jsxs(vt,{id:"gallery",title:(o==null?void 0:o.name)||"공간 갤러리",children:[u==="photos.carousel"?n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:h,onScroll:y,className:"flex snap-x snap-mandatory gap-3 overflow-x-auto [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden",children:c.map((T,D)=>n.jsx("li",{className:"w-full shrink-0 snap-center lg:w-auto",children:n.jsx(qo,{image:T,onOpen:()=>d(D),className:"aspect-4/3 rounded-lg"})},T.mediaId))}),c.length>1&&n.jsxs(n.Fragment,{children:[n.jsx(tp,{dir:"prev",show:x.prev,onClick:()=>z(-1)}),n.jsx(tp,{dir:"next",show:x.next,onClick:()=>z(1)}),n.jsxs("span",{className:"pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden",children:[Math.min(p+1,c.length)," / ",c.length]})]})]}):u==="photos.masonry"?n.jsx("ul",{className:"columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3",children:c.map((T,D)=>n.jsx("li",{className:"break-inside-avoid",children:n.jsx(qo,{image:T,onOpen:()=>d(D),className:"rounded-lg",free:!0})},T.mediaId))}):n.jsx("ul",{className:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4",children:c.map((T,D)=>n.jsx("li",{children:n.jsx(qo,{image:T,onOpen:()=>d(D),className:"aspect-square rounded-lg"})},T.mediaId))}),f!==null&&n.jsxs("div",{role:"dialog","aria-modal":"true","aria-label":"사진 크게 보기",className:"fixed inset-0 z-50 flex items-center justify-center bg-black/92 p-4 backdrop-blur-md",onClick:w,children:[n.jsx("button",{type:"button",onClick:w,"aria-label":"닫기",className:"tap absolute right-3 top-3 z-10 flex items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-5 sm:top-5",children:n.jsx(pp,{className:"size-6"})}),n.jsx(lp,{dir:"prev",onClick:()=>N(-1)}),n.jsx(lp,{dir:"next",onClick:()=>N(1)}),n.jsxs("figure",{className:"flex max-h-[85vh] w-full max-w-5xl flex-col items-center gap-3",onClick:T=>T.stopPropagation(),children:[n.jsx("img",{src:c[f].url,alt:c[f].alt,className:"max-h-[72vh] w-auto max-w-full rounded-lg object-contain"}),n.jsxs("figcaption",{className:"text-center text-[length:var(--fs-xs)] text-white/70",children:[c[f].caption??c[f].alt,n.jsxs("span",{className:"ml-2 tabular-nums",children:[f+1," / ",c.length]})]})]})]})]})}function tp({dir:i,show:c,onClick:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:o,"aria-label":i==="prev"?"이전 사진":"다음 사진","aria-hidden":!c,tabIndex:c?0:-1,className:`absolute top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${c?"opacity-100":"pointer-events-none opacity-0"} ${i==="prev"?"left-2":"right-2"}`,children:n.jsx(u,{className:"size-5"})})}function lp({dir:i,onClick:c}){const o=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:u=>{u.stopPropagation(),c()},"aria-label":i==="prev"?"이전 사진":"다음 사진",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 ${i==="prev"?"left-2 sm:left-6":"right-2 sm:right-6"}`,children:n.jsx(o,{className:"size-6"})})}function qo({image:i,onOpen:c,className:o,free:u=!1}){return n.jsx("button",{type:"button",onClick:c,className:`border-line tpl-border group relative block w-full cursor-pointer overflow-hidden border ${o}`,"aria-label":`${i.alt} 크게 보기`,children:n.jsx("img",{src:i.url,alt:i.alt,loading:"lazy",decoding:"async",width:i.width,height:i.height,className:u?"h-auto w-full":"size-full object-cover transition-transform duration-500 group-hover:scale-105"})})}function ap(){const i=le(),{place:c,routes:o}=i,[u,f]=q.useState(!1),d=c.roadAddress??c.address;if(!d)return null;const{latitude:h,longitude:p}=c,g=h!=null&&p!=null,x=yv(c.name,h,p),v=async()=>{try{await navigator.clipboard.writeText(d),f(!0),setTimeout(()=>f(!1),2e3)}catch{}};return n.jsxs(vt,{id:"location",title:"오시는 길",lead:"주소와 주요 거점까지의 이동 시간을 안내합니다.",children:[n.jsxs("div",{className:"grid gap-6 lg:grid-cols-12 lg:gap-8",children:[g&&n.jsx("div",{className:"border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7",children:n.jsx("iframe",{title:`${c.name} 위치 지도`,src:vv(h,p),loading:"lazy",referrerPolicy:"no-referrer-when-downgrade",className:"block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"})}),n.jsx("div",{className:g?"lg:col-span-5":"lg:col-span-12",children:n.jsxs("div",{className:"panel p-5 sm:p-6",children:[n.jsx("p",{className:"label mb-2",children:"주소"}),n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[n.jsx("address",{className:"text-[length:var(--fs-lead)] font-bold not-italic",children:d}),n.jsxs("button",{type:"button",onClick:v,title:"주소 복사",className:"border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70",children:[u?n.jsx(dp,{className:"size-3.5"}):n.jsx(kb,{className:"size-3.5"}),n.jsx("span",{children:u?"복사완료":"복사"})]})]}),c.phone&&n.jsxs("p",{className:"text-muted mt-2 text-[length:var(--fs-sm)]",children:["문의:"," ",n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),n.jsxs("div",{className:"mt-5 flex flex-wrap gap-2.5",children:[n.jsxs("a",{href:wp(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"네이버 길찾기"})]}),n.jsxs("a",{href:gv(c.name,h,p),target:"_blank",rel:"noopener noreferrer nofollow",className:"tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"카카오 길찾기"})]}),x&&n.jsxs("a",{href:x,className:"tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90",children:[n.jsx(Ho,{className:"size-4"}),n.jsx("span",{children:"티맵 길찾기"})]})]})]})})]}),o.length>0&&n.jsxs("div",{className:"mt-8",children:[n.jsxs("h3",{className:"mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold",children:[n.jsx(zb,{className:"size-4 opacity-50"}),n.jsx("span",{children:"주요 거점 소요시간"})]}),n.jsx("div",{className:"panel overflow-hidden",children:n.jsx("div",{className:"slider-viewport",children:n.jsxs("table",{className:"w-full min-w-[34rem] text-left text-[length:var(--fs-sm)]",children:[n.jsxs("caption",{className:"sr-only",children:[i.place.name,"에서 주요 거점까지의 이동 시간"]}),n.jsx("thead",{className:"panel-sunken border-line border-b font-semibold",children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",className:"p-4",children:"목적지"}),n.jsx("th",{scope:"col",className:"p-4",children:"차량"}),n.jsx("th",{scope:"col",className:"p-4",children:"도보 / 대중교통"}),n.jsx("th",{scope:"col",className:"p-4",children:"거리"}),n.jsx("th",{scope:"col",className:"hidden p-4 md:table-cell",children:"비고"})]})}),n.jsx("tbody",{className:"divide-line divide-y",children:o.map(y=>n.jsxs("tr",{children:[n.jsx("th",{scope:"row",className:"p-4 text-left font-bold",children:y.destination}),n.jsx("td",{className:"p-4 font-semibold",children:y.byCar??"—"}),n.jsx("td",{className:"p-4 opacity-70",children:y.byTransit??"—"}),n.jsx("td",{className:"text-muted p-4 tabular-nums",children:y.distanceText??"—"}),n.jsx("td",{className:"text-muted hidden p-4 md:table-cell",children:y.note??""})]},y.destination))})]})})})]})]})}function Dy(){const i=le(),c=D0(i);return c.length===0?null:n.jsx(vt,{id:"faq",tone:"alt",title:"자주 묻는 질문",lead:"아래 답변은 모두 사업자가 확인한 내용입니다.",children:n.jsx("div",{className:c.length>4?"grid gap-3 lg:grid-cols-2 lg:gap-4":"space-y-3",children:c.map((o,u)=>n.jsxs("details",{open:u===0,className:"panel group h-fit overflow-hidden",children:[n.jsxs("summary",{className:"tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden",children:[n.jsx("span",{className:"serif text-muted shrink-0",children:"Q."}),n.jsx("span",{className:"flex-1",children:o.question}),n.jsx("span",{className:"text-muted shrink-0 transition-transform group-open:rotate-45",children:"+"})]}),n.jsx("div",{className:"border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:o.answer})]},o.faqId))})})}function Op(){var d,h,p,g;const i=le(),{place:c,site:o}=i,u=_0(i),f=c.roadAddress??c.address;return n.jsx("footer",{className:"w-full pb-28 pt-14 md:pb-14",style:{backgroundColor:"var(--tpl-inverse, #1c1917)",color:"var(--tpl-bg, #ffffff)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid grid-cols-1 gap-8 border-b border-current/15 pb-10 md:grid-cols-12",children:[n.jsxs("div",{className:"space-y-4 md:col-span-7",children:[n.jsx("p",{className:"serif text-[length:var(--fs-lead)] font-bold",children:c.name}),n.jsxs("div",{className:"space-y-2 text-[length:var(--fs-sm)] opacity-75",children:[f&&n.jsxs("p",{className:"flex items-start gap-2",children:[n.jsx(Ls,{className:"mt-1 size-4 shrink-0 opacity-60"}),n.jsx("span",{children:f})]}),c.phone&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(At,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-2 hover:underline",children:c.phone})]}),c.email&&n.jsxs("p",{className:"flex items-center gap-2",children:[n.jsx(mp,{className:"size-4 shrink-0 opacity-60"}),n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-2 hover:underline",children:c.email})]})]})]}),u.length>0&&n.jsxs("nav",{"aria-label":"공식 채널",className:"md:col-span-5",children:[n.jsx("h2",{className:"label mb-3 !text-current opacity-60",children:"공식 채널"}),n.jsx("ul",{className:"flex flex-wrap gap-2",children:u.map(x=>n.jsx("li",{children:n.jsxs("a",{href:x.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10",children:[n.jsx("span",{children:ml(x)}),n.jsx(Db,{className:"size-3.5"})]})},x.url))})]})]}),n.jsxs("div",{className:"flex flex-col gap-3 pt-6 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",c.name]}),((d=c.legal)==null?void 0:d.representative)&&n.jsxs("span",{children:["대표: ",c.legal.representative]}),((h=c.legal)==null?void 0:h.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",c.legal.businessRegistrationNumber]}),((p=c.legal)==null?void 0:p.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",c.legal.mailOrderNumber]}),((g=c.legal)==null?void 0:g.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호",": ",c.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}function _n(){const i=le(),c=i.place.phone,u=Ze(i)[0],f=i.links.find(d=>d.confirmed&&/kakao/i.test(d.url));return!c&&!u&&!f?null:n.jsxs("nav",{"aria-label":"연락 · 예약",className:"border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 94%, transparent)"},children:[n.jsxs("a",{href:"#location",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":"오시는 길",children:[n.jsx(Ls,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"위치"})]}),f&&n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap border-line flex w-12 shrink-0 flex-col items-center justify-center rounded-lg border text-[10px] font-medium","aria-label":ml(f),children:[n.jsx(Io,{className:"size-4"}),n.jsx("span",{className:"mt-0.5",children:"문의"})]}),c&&n.jsxs("a",{href:`tel:${c}`,className:"tap border-line flex flex-1 items-center justify-center gap-1.5 rounded-lg border text-[length:var(--fs-sm)] font-bold",children:[n.jsx(At,{className:"size-4"}),n.jsx("span",{children:"전화"})]}),u&&n.jsx("a",{href:u.url,target:"_blank",rel:"noopener noreferrer",className:"tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)"},children:ht(u)})]})}const tl="var(--tpl-card, #fafafa)",Ce="var(--tpl-border, #d6d3d1)",We="color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)",Ms="var(--tpl-inverse, #1c1917)",ra="var(--tpl-bg, #ffffff)",ul="var(--tpl-text, #09090b)";function Ry({n:i}){return n.jsx("span",{className:"mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums",style:{backgroundColor:We,color:ra},children:i})}function Rl({id:i,name:c,subtitle:o,count:u,link:f,children:d,dark:h}){const p=hc(),g=p==="reservation"?iu:p==="oasi"?cu:p==="studio"?ru:p==="pastel"?ou:p==="editorial"?au:null,x=f?n.jsxs("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-75 transition-opacity hover:opacity-100 hover:underline",children:[f.label," →"]}):void 0;return n.jsx("section",{id:i,"aria-labelledby":`${i}-heading`,className:"border-line paper w-full border-b",style:{backgroundColor:h?Ms:"var(--tpl-surface, #fafafa)",color:h?ra:ul,paddingBlock:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[g?n.jsx(g,{id:i,title:c,lead:o,aside:x}):n.jsxs("header",{className:"mb-8 sm:mb-10",children:[n.jsxs("div",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2",children:[n.jsx("h2",{id:`${i}-heading`,className:"h2",children:c}),x]}),o&&n.jsx("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:o})]}),d,u&&n.jsx("p",{className:"mt-6 text-[length:var(--fs-xs)] opacity-60",children:u})]})})}function Gs({children:i,label:c,arrows:o="header",onSelect:u,onReady:f}){return n.jsx(gu,{label:c,gap:1,arrows:o,onSelect:u,onReady:f,children:i})}function fa({source:i,verified:c,imageCredit:o}){return!(i!=null&&i.name)&&!o?null:n.jsxs("p",{className:"flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70",children:[(i==null?void 0:i.name)&&n.jsxs("span",{children:["출처 ·"," ",i.url?n.jsx("a",{href:i.url,target:"_blank",rel:"noopener noreferrer nofollow",className:"underline underline-offset-2",children:i.name}):i.name]}),o&&n.jsxs("span",{children:["사진 · ",o]})]})}function Hs(i,c,o){return`총 ${i}${o}`}const np=We;function Dp(){const i=le(),c=dl(i,"songs"),[o,u]=q.useState(0);if(c.items.length===0)return null;const f=c.items[o]??c.items[0],d=h=>[h.artist,h.year?String(h.year):void 0,h.lyricist||h.composer?`작사 ${h.lyricist??"미상"} / 작곡 ${h.composer??"미상"}`:void 0,h.label].filter(Boolean).join(" · ");return n.jsxs(Rl,{id:"songs",name:c.title||Ye(i,"songs","가요 다방"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"곡"),children:[n.jsxs("div",{className:"tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]",style:{backgroundColor:Ms,color:ra,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsxs("div",{className:"relative mx-auto size-[220px]",children:[n.jsx("div",{className:"absolute inset-0 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 16%, transparent)"}}),n.jsx("div",{className:"w4-disc w4-spin absolute inset-2 rounded-full",style:{"--lbl":f.labelColor||np,"--w4-vinyl":Ms},children:n.jsx("span",{className:"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 30%, transparent)"}})}),n.jsxs("div",{"aria-hidden":!0,className:"absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6",children:[n.jsx("span",{className:"absolute inset-y-[3px] left-0 right-4 rounded-sm",style:{backgroundColor:"color-mix(in oklab, currentColor 65%, transparent)"}}),n.jsx("span",{className:"absolute -top-2 right-0 size-6 rounded-full",style:{backgroundColor:"color-mix(in oklab, currentColor 55%, transparent)"}})]})]}),n.jsx("div",{className:"min-w-0",children:c.items.map((h,p)=>n.jsxs("div",{hidden:p!==o,className:"space-y-3",children:[n.jsxs("p",{className:"text-[10px] tracking-[0.24em]",style:{color:We},children:["A면 · ",p+1," / ",c.items.length]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h2)] leading-tight",children:h.title}),n.jsx("p",{className:"text-[length:var(--fs-xs)] opacity-60",children:d(h)}),h.story&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:h.story}),h.connection&&n.jsx("p",{className:"measure text-[length:var(--fs-sm)] leading-relaxed",style:{color:We},children:h.connection}),n.jsxs("a",{href:xv(`${h.title} ${h.artist??""}`.trim()),target:"_blank",rel:"noopener noreferrer nofollow",className:"inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",style:{color:We},children:[n.jsx(a0,{className:"size-4"}),n.jsx("span",{children:"유튜브에서 듣기"})]}),n.jsx("span",{className:"block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60",style:{borderColor:"color-mix(in oklab, currentColor 35%, transparent)"},children:"◎ 가사 대신 이야기 — 원문은 싣지 않습니다"}),n.jsx(fa,{source:h.source,verified:h.verified})]},`disc-${h.title}-${p}`))})]}),c.items.length>1&&n.jsx("div",{className:"mt-4 flex flex-wrap justify-center gap-4",children:c.items.map((h,p)=>n.jsxs("button",{type:"button",onClick:()=>u(p),"aria-pressed":p===o,className:"w-24 text-center",children:[n.jsx("span",{className:"w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105",style:{"--lbl":h.labelColor||np,"--w4-vinyl":Ms,boxShadow:p===o?`0 0 0 2px ${We}`:void 0}}),n.jsx("span",{className:"mt-2 block truncate text-[10px] leading-tight opacity-70",children:h.title})]},`${h.title}-${p}`))})]})}function Rp(){const i=le(),c=dl(i,"daily"),[o,u]=q.useState();if(q.useEffect(()=>{const g=new Date;u(`${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`)},[]),c.items.length===0)return null;const f=[...c.items].sort((g,x)=>g.monthDay.localeCompare(x.monthDay)),d=o===void 0?1:Math.max(0,(()=>{const g=f.findIndex(v=>v.monthDay===o);if(g>=0)return g;const x=f.findIndex(v=>v.monthDay>o);return x>=0?x:0})()),h=g=>f[(d+g+f.length)%f.length],p=f.length===1?[h(0)]:f.length===2?[h(0),h(1)]:[h(-1),h(0),h(1)];return n.jsx(Rl,{id:"daily",name:c.title||Ye(i,"daily","오늘의 한 장"),subtitle:c.subtitle,count:Hs(f.length,c.unverified,"장"),children:n.jsx("div",{className:"flex items-start justify-center gap-4 sm:gap-6",children:f.map((g,x)=>{const v=p.indexOf(g);return n.jsx(Uy,{page:g,hidden:v<0,isToday:p.length===1||v===1,muted:p.length>1&&v!==1},`${g.monthDay}-${x}`)})})})}function Uy({page:i,isToday:c,muted:o,hidden:u}){const[f,d]=i.monthDay.split("-");return n.jsxs("article",{hidden:u,className:`w4-paper shrink-0 border transition-opacity ${o?"hidden w-[168px] opacity-45 sm:block":"w-[254px] max-w-full"}`,style:{backgroundColor:tl,borderColor:c?We:Ce,boxShadow:c?"5px 6px 0 color-mix(in oklab, currentColor 14%, transparent)":void 0},"aria-current":c?"date":void 0,children:[n.jsx("div",{className:"w4-perf h-3.5 border-b border-dashed",style:{"--tear":tl,borderColor:Ce}}),n.jsxs("div",{className:"border-b px-5 pb-3 pt-5 text-center",style:{borderColor:Ce},children:[n.jsxs("p",{className:"text-[11px] tracking-[0.2em] opacity-60",children:[f,"月"]}),n.jsx("p",{className:`serif mt-1 font-bold leading-none ${o?"text-4xl":"text-6xl"}`,style:{color:We},children:String(Number(d)||d)}),c&&n.jsx("p",{className:"mt-1.5 text-[10px] tracking-[0.3em]",style:{color:We},children:"오늘"})]}),n.jsxs("div",{className:"space-y-2 px-5 pb-5 pt-4",children:[i.category&&n.jsx("p",{className:"text-[10px] tracking-[0.16em] opacity-60",children:i.category}),n.jsx("h3",{className:"serif text-base font-bold leading-snug",children:i.title}),!o&&i.body&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.body}),!o&&n.jsx("div",{className:"border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})]})}function Up(){const i=le(),c=dl(i,"people");return c.items.length===0?null:n.jsx(Rl,{id:"people",name:c.title||Ye(i,"people","인물 열전"),subtitle:c.subtitle,count:c.items.some(o=>!o.imageUrl)?`${Hs(c.items.length,c.unverified,"명")} · 사진이 없는 인물은 이름 활자로 대신합니다`:Hs(c.items.length,c.unverified,"명"),dark:!0,children:n.jsxs("div",{className:"tpl-border border",style:{backgroundColor:Ms,borderColor:"color-mix(in oklab, currentColor 22%, transparent)"},children:[n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"}),n.jsx("div",{className:"px-3 py-4",children:n.jsx(Gs,{label:"인물 목록",arrows:"overlay",children:c.items.map((o,u)=>n.jsxs("article",{className:"w-[210px] shrink-0 snap-center",children:[o.imageUrl?n.jsx("img",{src:o.imageUrl,alt:`${o.name} 사진`,loading:"lazy",decoding:"async",className:"aspect-3/4 w-full border object-cover",style:{borderColor:"color-mix(in oklab, currentColor 28%, transparent)"}}):n.jsx("span",{className:"serif grid aspect-3/4 w-full place-items-center border text-5xl",style:{backgroundColor:"color-mix(in oklab, currentColor 14%, transparent)",borderColor:"color-mix(in oklab, currentColor 28%, transparent)"},"aria-hidden":!0,children:o.name.trim().charAt(0)}),n.jsxs("h3",{className:"serif mt-3 text-base font-bold",children:[o.name,o.aka&&n.jsxs("span",{className:"ml-1.5 text-xs opacity-60",children:["호 ",o.aka]})]}),n.jsx("p",{className:"mt-0.5 text-[11px] opacity-60",children:[o.role,o.years].filter(Boolean).join(" · ")}),o.oneLine&&n.jsx("p",{className:"mt-2 text-[13px] leading-relaxed opacity-85",children:o.oneLine}),n.jsx("div",{className:"mt-2",children:n.jsx(fa,{source:o.source,verified:o.verified,imageCredit:o.imageCredit})})]},`${o.name}-${u}`))})}),n.jsx("div",{className:"w4-film-perf h-2.5 opacity-70"})]})})}function Hp(){const i=le(),c=dl(i,"chronicle");if(c.items.length===0)return null;const o=[...c.items].sort((f,d)=>f.year==null?d.year==null?0:1:d.year==null?-1:f.year-d.year),u=o.filter(f=>f.turning===!0).length;return n.jsx(Rl,{id:"chronicle",name:c.title||Ye(i,"chronicle","시간의 골목"),subtitle:c.subtitle,count:`붉은 점은 도시의 성격이 바뀐 해입니다 · ${u}개 / 전체 ${o.length}개`,children:n.jsx(Gs,{label:"연표",children:o.map((f,d)=>{const h=f.turning===!0;return n.jsxs("article",{className:"w-[228px] shrink-0 snap-center",children:[n.jsx("p",{className:"serif text-3xl font-bold leading-none",style:{color:h?We:void 0},children:f.year??"연도 미상"}),n.jsxs("div",{className:"relative my-3 h-3",children:[n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 border-t",style:{borderColor:Ce,right:d===o.length-1?"calc(100% - 12px)":0}}),n.jsx("i",{"aria-hidden":!0,className:"absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2",style:{backgroundColor:h?We:tl,borderColor:h?We:Ce}})]}),n.jsxs("div",{className:"flex gap-2.5 pr-5",children:[f.imageUrl&&n.jsx("img",{src:f.imageUrl,alt:`${f.title} 사진`,loading:"lazy",decoding:"async",className:"size-14 shrink-0 border object-cover",style:{borderColor:Ce}}),n.jsxs("div",{className:"min-w-0 flex-1 space-y-1.5",children:[n.jsx("h3",{className:"serif text-base font-bold",children:f.title}),f.summary&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:f.summary}),f.place&&n.jsxs("p",{className:"text-[11px] opacity-60",children:["지금 이 자리 · ",f.place]}),n.jsx(fa,{source:f.source,verified:f.verified,imageCredit:f.imageCredit})]})]})]},`${f.year??"x"}-${f.title}-${d}`)})})})}function Lp(){const i=le(),c=dl(i,"postcard");return c.items.length===0?null:n.jsx(Rl,{id:"postcard",name:c.title||Ye(i,"postcard","오늘의 엽서"),subtitle:c.subtitle,count:Hs(c.items.length,c.unverified,"장"),children:n.jsx(Gs,{label:"엽서 목록",children:c.items.map((o,u)=>n.jsxs("article",{className:"w4-paper w-[304px] shrink-0 snap-center border p-4",style:{backgroundColor:tl,borderColor:ul},children:[o.imageUrl&&n.jsx("img",{src:o.imageUrl,alt:`${o.place||o.postmark||"엽서"} 사진`,loading:"lazy",decoding:"async",className:"block aspect-3/2 w-full border object-cover",style:{borderColor:Ce}}),o.imageUrl&&n.jsxs("p",{className:"mt-1 mb-3.5 text-[10px] leading-tight opacity-45",children:["사진 · ",o.imageCredit||"출처 표기 없음"]}),n.jsxs("div",{className:"grid grid-cols-[minmax(0,1fr)_auto] gap-3",children:[n.jsxs("div",{className:"min-w-0 border-r pr-3",style:{borderColor:Ce},children:[n.jsxs("p",{className:"serif text-[17px] leading-snug",children:["“",o.line,"”"]}),o.hashtags&&o.hashtags.length>0&&n.jsx("p",{className:"mt-2 text-[11px] opacity-60",children:o.hashtags.join(" ")})]}),n.jsxs("div",{className:"flex w-[62px] flex-col items-center gap-3",children:[n.jsxs("span",{className:"grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60",style:{borderColor:Ce},children:["郵票",n.jsx("br",{}),"10원"]}),n.jsx("span",{className:"serif grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight",style:{borderColor:We,color:We},children:o.postmark||o.place||"소인"})]})]}),n.jsx("div",{className:"mt-3 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:o.source,verified:o.verified})})]},`${o.line}-${u}`))})})}function Hy(){const i=le(),c=dl(i,"quiz"),[o,u]=q.useState(new Set);if(c.items.length===0)return null;const f=d=>u(h=>{const p=new Set(h);return p.has(d)?p.delete(d):p.add(d),p});return n.jsx(Rl,{id:"quiz",name:c.title||Ye(i,"quiz","뒤집어 보는 질문"),subtitle:c.subtitle,count:`정답은 두지 않습니다 — 힌트와 출처까지만 · 총 ${c.items.length}문항`,children:n.jsx(Gs,{label:"질문 목록",children:c.items.map((d,h)=>{const p=o.has(h);return n.jsx("button",{type:"button",onClick:()=>f(h),"aria-pressed":p,"aria-label":`${h+1}번 문제 ${p?"앞면 보기":"힌트 보기"}`,className:`w4-flip ${p?"w4-flip-on":""} h-[260px] w-[262px] shrink-0 text-left`,children:n.jsxs("span",{className:"w4-flip-inner block size-full",children:[n.jsxs("span",{className:"w4-flip-face w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:ul},children:[n.jsxs("span",{className:"text-[10px] tracking-[0.16em] opacity-60",children:["문제 ",h+1,d.level?` · ${d.level}`:""]}),n.jsx("span",{className:"serif text-[17px] font-bold leading-snug",children:d.question}),n.jsx("span",{className:"mt-auto text-[10px] tracking-[0.16em]",style:{color:We},children:"눌러서 힌트 보기 →"})]}),n.jsxs("span",{className:"w4-flip-face w4-flip-back w4-paper flex size-full flex-col gap-3 border p-4",style:{backgroundColor:tl,borderColor:We},children:[n.jsx("span",{className:"text-[10px] tracking-[0.16em]",style:{color:We},children:"힌트"}),n.jsx("span",{className:"text-[13px] leading-relaxed opacity-85",children:d.hint??"힌트가 아직 없습니다."}),n.jsxs("span",{className:"mt-auto space-y-1 border-t border-dashed pt-2.5",style:{borderColor:Ce},children:[d.topic&&n.jsx("span",{className:"block text-[10px] opacity-55",children:d.topic}),n.jsx(fa,{source:d.source,verified:d.verified})]})]})]})},`${d.question}-${h}`)})})})}const Dl=256,_s=318,Nn=168,sp=34,Ly=16,ip=4;function Bp(i,c,o){const u=Dl*2**o,f=i*Math.PI/180;return{x:(c+180)/360*u,y:(1-Math.log(Math.tan(f)+1/Math.cos(f))/Math.PI)/2*u}}function By(i){if(i.length<2)return 15;for(let c=Ly;c>ip;c-=1){const o=i.map(d=>Bp(d.lat,d.lng,c)),u=Math.max(...o.map(d=>d.x))-Math.min(...o.map(d=>d.x)),f=Math.max(...o.map(d=>d.y))-Math.min(...o.map(d=>d.y));if(u<=_s-sp*2&&f<=Nn-sp*2)return c}return ip}function qy(i,c){const o=d=>d.latitude!=null&&d.longitude!=null?{name:d.searchQuery??d.name,lat:d.latitude,lng:d.longitude}:void 0,u=[];let f=c;return i.forEach((d,h)=>{const p=o(d);p&&(u.push({label:String(h+1),...p,stop:d,from:f&&!Yy(f,p)?f:void 0}),f=p)}),u}function Yy(i,c){return Math.abs(i.lat-c.lat)<1e-5&&Math.abs(i.lng-c.lng)<1e-5}function Gy(i){const o=i.map(u=>({...u}));for(let u=1;u<o.length;u+=1)for(let f=0;f<6;f+=1){const d=o.slice(0,u).find(v=>Math.hypot(o[u].x-v.x,o[u].y-v.y)<27);if(!d)break;const h=o[u].x-d.x,p=o[u].y-d.y,g=Math.hypot(h,p)||1,x=(27-g)/g;o[u].x+=(h||1)*x,o[u].y+=p*x}return o}function $y(i){return i.from?bv(i.from,{name:i.stop.name,lat:i.lat,lng:i.lng}):wp(i.stop.searchQuery??i.stop.name,i.lat,i.lng)}function Qy({stops:i}){const{place:c}=le(),o=c.latitude!=null&&c.longitude!=null?{name:c.name,lat:c.latitude,lng:c.longitude}:void 0,u=i.filter(N=>N.latitude!=null&&N.longitude!=null);if(u.length===0||u.length===1&&i.length>1)return null;const f=qy(i,o),d=By(f),h=Gy(f.map(N=>Bp(N.lat,N.lng,d))),p=(Math.min(...h.map(N=>N.x))+Math.max(...h.map(N=>N.x)))/2,g=(Math.min(...h.map(N=>N.y))+Math.max(...h.map(N=>N.y)))/2,x=p-_s/2,v=g-Nn/2,y=2**d,z=[];for(let N=Math.floor(x/Dl);N<=Math.floor((x+_s)/Dl);N+=1)for(let T=Math.floor(v/Dl);T<=Math.floor((v+Nn)/Dl);T+=1){if(T<0||T>=y)continue;const D=(N%y+y)%y;z.push({key:`${N}-${T}`,url:`https://tile.openstreetmap.org/${d}/${D}/${T}.png`,left:N*Dl-x,top:T*Dl-v})}const w=h.map(N=>`${(N.x-x).toFixed(1)},${(N.y-v).toFixed(1)}`).join(" ");return n.jsxs("div",{className:"relative mt-3.5 overflow-hidden border-y",style:{height:Nn,borderColor:Ce,backgroundColor:"#e8e5df"},children:[z.map(N=>n.jsx("img",{src:N.url,alt:"",width:Dl,height:Dl,loading:"lazy",decoding:"async",className:"pointer-events-none absolute max-w-none",style:{left:N.left,top:N.top,filter:"grayscale(1) contrast(0.95) opacity(0.55)"}},N.key)),n.jsx("svg",{className:"pointer-events-none absolute inset-0",width:_s,height:Nn,viewBox:`0 0 ${_s} ${Nn}`,children:n.jsx("polyline",{points:w,fill:"none",stroke:We,strokeWidth:"2",strokeDasharray:"5 4",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.85"})}),f.map((N,T)=>{const D=h[T];return n.jsx("a",{href:$y(N),target:"_blank",rel:"noopener noreferrer",title:N.from?`${N.from.name} → ${N.stop.name} 길찾기`:`${N.stop.name} — 네이버 지도`,className:"absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full px-1.5 text-[11px] font-bold whitespace-nowrap shadow-sm transition-transform hover:scale-110",style:{left:D.x-x,top:D.y-v,backgroundColor:We,color:ra,border:`1.5px solid ${ra}`},children:N.label},N.label)}),n.jsx("span",{className:"absolute right-1 bottom-0.5 bg-white/70 px-1 text-[9px] leading-tight text-stone-600",children:"© OpenStreetMap"})]})}function Xy(i){const c=Math.floor(i/60),o=i%60;return[c>0?`${c}시간`:"",o>0?`${o}분`:""].filter(Boolean).join(" ")||"0분"}function Jo(i){var c;return(c=i.days)!=null&&c.length?i.days.map(o=>({label:o.label,startTime:o.startTime,stops:o.stops??[]})):[{startTime:i.startTime,stops:i.stops??[]}]}function Vy({item:i,badge:c,startTime:o,stops:u,first:f,placeName:d}){const h=z0({name:i.name,startTime:o,stops:u});return n.jsxs("article",{className:"w4-paper w-[320px] shrink-0 snap-center border",style:{backgroundColor:tl,borderColor:Ce},children:[n.jsxs("div",{className:"flex items-center justify-between gap-2 border-b px-4 py-2.5",style:{borderColor:Ce},children:[n.jsx("span",{className:"border px-2 py-0.5 text-[11px] font-bold",style:f?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:c}),n.jsxs("span",{className:"text-[11px] opacity-60",children:[h.from,"–",h.to," · ",Xy(h.totalMinutes)]})]}),n.jsxs("div",{className:"space-y-1.5 px-4 pt-3.5",children:[n.jsx("h3",{className:"serif text-lg font-bold",children:i.name}),f&&i.audience&&n.jsx("p",{className:"text-[12px] opacity-60",children:i.audience}),f&&i.why&&n.jsx("p",{className:"text-[13px] leading-relaxed opacity-80",children:i.why}),f&&n.jsxs("p",{className:"text-[11px] opacity-50",children:[d,"에서 출발 · ",h.from]})]}),n.jsx(Qy,{stops:h.stops.map(p=>p.stop)}),n.jsx("ol",{className:"mt-3 px-4 pb-3",children:h.stops.map((p,g)=>n.jsxs("li",{className:"grid grid-cols-[46px_minmax(0,1fr)] gap-2.5",children:[n.jsx("span",{className:"serif pt-2 text-[12px] tabular-nums opacity-75",children:p.time}),n.jsxs("div",{className:"border-l pb-5 pl-3",style:{borderColor:Ce},children:[p.move>0&&n.jsxs("p",{className:"pt-1 text-[10px] opacity-50",children:["↓ ",p.move,"분 이동"]}),n.jsxs("div",{className:"flex items-start gap-2.5 pt-1",children:[p.stop.imageUrl&&n.jsx("img",{src:p.stop.imageUrl,alt:`${p.stop.name} 사진`,loading:"lazy",decoding:"async",className:"size-12 shrink-0 object-cover",style:{border:`1px solid ${Ce}`}}),n.jsxs("span",{className:"min-w-0",children:[n.jsxs("span",{className:"block text-sm font-bold",children:[n.jsx(Ry,{n:g+1}),p.stop.name]}),p.stop.note&&n.jsx("span",{className:"mt-0.5 block text-[12px] leading-relaxed opacity-75",children:p.stop.note})]})]}),n.jsxs("p",{className:"mt-0.5 text-[10px] opacity-50",children:[p.time,"–",p.until,p.stop.searchQuery&&` · 지도 검색 ${p.stop.searchQuery}`]})]})]},`${p.stop.name}-${g}`))}),n.jsx("div",{className:"border-t border-dashed px-4 py-2.5",style:{borderColor:Ce},children:n.jsx(fa,{source:i.source,verified:i.verified})})]})}const Yo="그 밖의 일정";function Zy(){var g;const i=le(),c=dl(i,"itinerary"),u=(c.items.length>0?c.items:((g=i.local)==null?void 0:g.itineraries)??[]).filter(x=>Jo(x).some(v=>v.stops.length>0)),f=[...new Set(u.map(x=>{var v;return((v=x.duration)==null?void 0:v.trim())||Yo}))],[d,h]=q.useState(null),p=d??f[0];return u.length===0?null:n.jsxs(Rl,{id:"itinerary",name:c.title||Ye(i,"itinerary","추천 일정"),subtitle:c.subtitle,count:"시각은 출발 시각과 머무는 시간으로 계산한 것입니다",children:[f.length>1&&n.jsx("div",{className:"mb-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"묵는 기간",children:f.map(x=>{const v=p===x;return n.jsxs("button",{type:"button",role:"tab","aria-selected":v,onClick:()=>h(x),className:"border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:v?{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg, #fff)",borderColor:"transparent"}:void 0,children:[x,n.jsx("span",{className:"ml-1.5 font-normal opacity-60",children:u.filter(y=>{var z;return(((z=y.duration)==null?void 0:z.trim())||Yo)===x}).length})]},x)})}),f.map(x=>n.jsx("div",{hidden:x!==p,children:n.jsx(Ky,{tab:x,items:u.filter(v=>{var y;return(((y=v.duration)==null?void 0:y.trim())||Yo)===x}),placeName:i.place.name},p)},x))]})}function Ky({tab:i,items:c,placeName:o}){const u=q.useMemo(()=>{let x=0;return c.map(v=>{const y=x;return x+=Jo(v).length,{item:v,start:y}})},[c]),[f,d]=q.useState(0),h=q.useRef(null),p=q.useCallback(x=>{h.current=x},[]),g=u.reduce((x,v)=>v.start<=f?v.start:x,0);return n.jsxs(n.Fragment,{children:[u.length>1&&n.jsx("ul",{className:"border-line mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3",children:u.map(({item:x,start:v})=>{const y=v===g;return n.jsx("li",{children:n.jsx("button",{type:"button",onClick:()=>{var z;return(z=h.current)==null?void 0:z.scrollTo(v)},"aria-current":y,className:`text-left text-[length:var(--fs-sm)] underline-offset-4 transition-opacity hover:opacity-100 ${y?"font-bold underline":"opacity-55 hover:underline"}`,children:x.name})},`${x.name}-${v}`)})}),n.jsx(Gs,{label:`${i} 일정`,onSelect:d,onReady:p,children:u.flatMap(({item:x,start:v},y)=>Jo(x).map((z,w)=>n.jsx(Vy,{item:x,badge:z.label??x.duration??`${w+1}일차`,startTime:z.startTime,stops:z.stops,first:w===0,placeName:o},`${x.name}-${v}-${w}`)))})]})}function Jy(){const i=le(),c=dl(i,"video"),[o,u]=q.useState(),f=q.useRef(null),[d,h]=q.useState({prev:!1,next:!0}),p=q.useCallback(()=>{const v=f.current;if(!v)return;const y=v.scrollWidth-v.clientWidth;h({prev:v.scrollLeft>8,next:v.scrollLeft<y-8})},[]);q.useEffect(()=>(p(),window.addEventListener("resize",p),()=>window.removeEventListener("resize",p)),[p]);const g=q.useCallback(v=>{const y=f.current;if(!y)return;const z=window.matchMedia("(prefers-reduced-motion: reduce)").matches;y.scrollBy({left:v*y.clientWidth*.85,behavior:z?"auto":"smooth"})},[]);if(xu({box:f,interval:pu,advance:()=>Cp(f.current)}),c.items.length===0)return null;const x=c.items.length>1;return n.jsx(Rl,{id:"video",name:c.title||Ye(i,"video","영상으로 보기"),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,children:n.jsxs("div",{className:"relative",children:[n.jsx("ul",{ref:f,onScroll:p,className:x?"flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:gap-4 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden":"grid grid-cols-1 gap-4",children:c.items.map((v,y)=>{const z=p0(v.url),w=x0(v.url),N=o===y;return n.jsx("li",{className:x?"shrink-0 basis-[86%] snap-start sm:basis-[56%] lg:basis-auto":"",children:n.jsxs("figure",{className:"space-y-2",children:[n.jsx("div",{className:"tpl-border relative mx-auto overflow-hidden border",style:{borderColor:Ce,backgroundColor:tl,aspectRatio:w?"9 / 16":"16 / 9",width:w?"min(100%, 22rem)":"100%"},children:N&&z?n.jsx("iframe",{src:b0(z),title:v.caption||"영상",allow:"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",allowFullScreen:!0,className:"size-full border-0"}):n.jsxs("a",{href:v.url,target:"_blank",rel:"noopener noreferrer",onClick:T=>{z&&(T.preventDefault(),u(y))},"aria-label":`${v.caption||"영상"} 재생`,className:"group block size-full",children:[z?n.jsx("img",{src:g0(z),alt:"",loading:"lazy",decoding:"async",className:"size-full object-cover"}):n.jsx("span",{className:"text-muted grid size-full place-items-center px-4 text-center text-[length:var(--fs-xs)]",children:"유튜브 영상이 아닙니다 — 눌러서 원본으로 갑니다"}),n.jsx("span",{className:"absolute inset-0 grid place-items-center",children:n.jsx("span",{className:"grid size-14 place-items-center rounded-full backdrop-blur transition-transform group-hover:scale-110",style:{backgroundColor:"color-mix(in srgb, var(--tpl-inverse, #1c1917) 70%, transparent)",color:"var(--tpl-bg, #fff)"},children:n.jsx(Vb,{className:"size-6"})})})]})}),v.caption&&n.jsx("figcaption",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-80",children:v.caption}),n.jsx(fa,{source:v.source,verified:v.verified})]})},`${v.url}-${y}`)})}),x&&n.jsxs(n.Fragment,{children:[n.jsx(cp,{dir:"prev",onClick:()=>g(-1),disabled:!d.prev}),n.jsx(cp,{dir:"next",onClick:()=>g(1),disabled:!d.next})]})]})})}function cp({dir:i,onClick:c,disabled:o}){const u=i==="prev"?oa:ua;return n.jsx("button",{type:"button",onClick:c,disabled:o,"aria-label":i==="prev"?"이전 영상":"다음 영상",className:`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 lg:hidden ${i==="prev"?"left-1":"right-1"}`,children:n.jsx(u,{className:"size-5"})})}const Wy={1:"한",2:"두",3:"세",4:"네",5:"다섯"},Fy=[{id:"songs",label:"가요 다방",Component:Dp},{id:"daily",label:"오늘의 한 장",Component:Rp},{id:"people",label:"인물 열전",Component:Up},{id:"chronicle",label:"시간의 골목",Component:Hp},{id:"postcard",label:"오늘의 엽서",Component:Lp}];function Iy(){const i=le(),c=Fy.filter(f=>dl(i,f.id).items.length>0),[o,u]=q.useState(0);return c.length===0?null:n.jsxs(n.Fragment,{children:[n.jsx("section",{id:"story","aria-labelledby":"story-heading",className:"paper w-full",style:{backgroundColor:"var(--tpl-surface, #fafafa)",color:ul,paddingTop:"var(--section-space)",paddingBottom:"calc(var(--section-space) * 0.55)"},children:n.jsxs("div",{className:"shell",children:[n.jsx("h2",{id:"story-heading",className:"h2",children:Ye(i,"story","군산 이야기")}),n.jsxs("p",{className:"measure mt-3 text-[length:var(--fs-sm)] opacity-70",children:["이 도시를 ",Wy[c.length]??`${c.length}`," 갈래로 봅니다. 하나씩 골라 보세요."]}),n.jsx("div",{className:"mt-6 flex flex-wrap gap-1.5",role:"tablist","aria-label":"군산 이야기",children:c.map((f,d)=>{const h=d===o;return n.jsx("button",{type:"button",role:"tab","aria-selected":h,"aria-controls":f.id,onClick:()=>u(d),className:"rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80",style:h?{backgroundColor:ul,color:ra,borderColor:ul}:{borderColor:Ce},children:f.label},f.id)})})]})}),c.map((f,d)=>n.jsx("div",{hidden:d!==o,children:n.jsx(f.Component,{})},f.id))]})}const Py={open:"진행 중",soon:"곧 시작",closed:"종료"};function rp(i,c){return i.endDate&&i.endDate<c?"closed":i.startDate&&i.startDate>c?"soon":"open"}function Go(i,c){return i.kind==="공지"&&!i.startDate&&!i.endDate?"공지":c&&Py[c]}function $o(i){const c=f=>{const d=/^(\d{4})-(\d{2})-(\d{2})$/.exec(f??"");return d?`${Number(d[2])}월 ${Number(d[3])}일`:void 0},o=c(i.startDate),u=c(i.endDate);if(o&&u)return o===u?o:`${o} – ${u}`;if(o)return`${o}부터`;if(u)return`${u}까지`}function e1(){const i=le(),c=dl(i,"event"),[o,u]=q.useState();q.useEffect(()=>u(new Date().toLocaleDateString("sv-SE")),[]);const[f,d]=q.useState(null),h=q.useCallback(()=>d(null),[]);if(q.useEffect(()=>{if(f===null)return;const x=y=>{y.key==="Escape"&&h()},v=document.body.style.overflow;return document.body.style.overflow="hidden",window.addEventListener("keydown",x),()=>{document.body.style.overflow=v,window.removeEventListener("keydown",x)}},[f,h]),c.items.length===0)return null;const p=f===null?void 0:c.items[f],g=p&&o?rp(p,o):void 0;return n.jsxs(Rl,{id:"event",name:c.title||Ye(i,"event",`${i.place.name} 소식`),subtitle:c.subtitle,link:c.linkUrl?{url:c.linkUrl,label:c.linkLabel??"더 보기"}:void 0,count:"자세한 내용과 최신 소식은 원문에서 확인해 주세요.",children:[n.jsx("ul",{className:"grid gap-5 sm:grid-cols-2 lg:grid-cols-3",children:c.items.map((x,v)=>{const y=o?rp(x,o):void 0,z=Go(x,y),w=$o(x),N=y==="closed";return n.jsxs("li",{className:"w4-paper relative flex flex-col border-2",style:{backgroundColor:tl,borderColor:Ce,opacity:N?.6:1,boxShadow:N?"none":"3px 3px 0 color-mix(in oklab, currentcolor 12%, transparent)"},children:[n.jsx("button",{type:"button",onClick:()=>d(v),"aria-label":`${x.title} 자세히 보기`,className:"absolute inset-0 z-20 cursor-pointer"}),n.jsx("div",{className:x.imageUrl?"relative overflow-hidden border-b-2":"contents",style:x.imageUrl?{borderColor:Ce}:void 0,children:x.imageUrl?n.jsx("img",{src:x.imageUrl,alt:`${x.title} 안내 이미지`,loading:"lazy",decoding:"async",className:"aspect-4/3 w-full object-cover",style:N?{filter:"grayscale(1)"}:void 0}):null}),n.jsxs("div",{className:"flex flex-1 flex-col gap-2.5 p-5",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[z&&n.jsx("span",{className:"border-2 px-2 py-0.5 text-[11px] font-bold tracking-[0.08em]",style:{transform:"rotate(-3deg)",backgroundColor:y==="open"&&z!=="공지"?We:tl,color:y==="open"&&z!=="공지"?ra:ul,borderColor:y==="open"&&z!=="공지"?We:Ce},children:z}),w&&n.jsx("span",{className:"text-[11px] font-semibold tabular-nums opacity-55",children:w})]}),n.jsx("h3",{className:"serif text-lg font-bold leading-snug",children:x.title}),x.summary&&n.jsx("p",{className:"line-clamp-2 text-[13px] leading-relaxed opacity-80",children:x.summary}),x.howTo&&n.jsx("p",{className:"border-2 border-dashed px-3 py-2 text-[12px] leading-relaxed",style:{borderColor:Ce},children:x.howTo}),x.body&&n.jsx("p",{className:`whitespace-pre-line text-[12px] leading-relaxed opacity-70 ${x.imageUrl?"line-clamp-3":"line-clamp-[9]"}`,children:x.body}),n.jsx("p",{className:"mt-auto pt-2 text-[12px] font-bold opacity-70",children:"자세히 보기 →"})]})]},`${x.title}-${v}`)})}),p&&n.jsx("div",{role:"dialog","aria-modal":"true","aria-label":p.title,className:"fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6",onClick:h,children:n.jsxs("article",{onClick:x=>x.stopPropagation(),className:"w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2",style:{backgroundColor:tl,borderColor:Ce,color:ul},children:[p.imageUrl&&n.jsx("img",{src:p.imageUrl,alt:`${p.title} 안내 이미지`,className:"max-h-[46vh] w-full object-contain",style:{backgroundColor:"color-mix(in oklab, currentcolor 6%, transparent)"}}),n.jsxs("div",{className:"space-y-4 p-6 sm:p-8",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[Go(p,g)&&n.jsx("span",{className:"border-2 px-2.5 py-1 text-[11px] font-bold tracking-[0.08em]",style:{borderColor:Ce},children:Go(p,g)}),$o(p)&&n.jsx("span",{className:"text-[12px] font-semibold tabular-nums opacity-60",children:$o(p)})]}),n.jsx("h3",{className:"serif text-[length:var(--fs-h3)] font-bold leading-snug",children:p.title}),p.summary&&n.jsx("p",{className:"text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.summary}),p.howTo&&n.jsx("p",{className:"border-2 border-dashed px-4 py-3 text-[length:var(--fs-sm)] leading-relaxed",style:{borderColor:Ce},children:p.howTo}),p.body&&n.jsx("p",{className:"whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-85",children:p.body}),n.jsxs("div",{className:"flex items-center justify-between gap-4 pt-2",children:[p.postUrl?n.jsx("a",{href:p.postUrl,target:"_blank",rel:"noopener noreferrer",className:"text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline",children:"원문 보기 →"}):n.jsx("span",{}),n.jsxs("button",{type:"button",onClick:h,className:"tap inline-flex items-center gap-1.5 border-2 px-4 text-[length:var(--fs-sm)] font-bold",style:{borderColor:Ce},children:[n.jsx(pp,{className:"size-4"}),n.jsx("span",{children:"닫기"})]})]})]})]})})]})}const t1={songs:Dp,daily:Rp,people:Up,chronicle:Hp,postcard:Lp,quiz:Hy,itinerary:Zy,video:Jy,event:e1,story:Iy};function l1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(s1,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(i1,{}),n.jsx(_n,{})]})}function qp(){const i=le(),c=ft(i);return[{id:"intro",href:"#about",fallback:"소개"},{id:c.path,href:"#units",fallback:c.label},{id:"info",href:"#info",fallback:"이용 정보"},{id:"booking",href:"#booking",fallback:"예약 안내"},{id:"photos",href:"#gallery",fallback:"사진"},{id:"map",href:"#location",fallback:"오시는 길"},{id:"local",href:"#guide",fallback:"주변 정보"},{id:"faq",href:"#faq",fallback:"자주 묻는 질문"}].filter(o=>Kt(i,o.id)).map(o=>({label:Ye(i,o.id,o.fallback),href:o.href}))}const a1=24,n1="0 1px 3px color-mix(in srgb, var(--tpl-inverse, #1c1917) 55%, transparent)";function s1(){const i=le(),c=qp(),[o,u]=q.useState(!1);q.useEffect(()=>{const d=()=>u(window.scrollY>a1);return d(),window.addEventListener("scroll",d,{passive:!0}),()=>window.removeEventListener("scroll",d)},[]);const f=o?"opacity-70 transition-opacity hover:opacity-100":"underline-offset-4 hover:underline";return n.jsx("header",{className:`fixed inset-x-0 top-0 z-40 w-full transition-colors duration-300 ${o?"border-line border-b backdrop-blur-md":""}`,style:o?{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"}:{color:"var(--tpl-bg, #ffffff)",textShadow:n1},children:n.jsxs("div",{className:"shell grid h-20 grid-cols-[1fr_auto] items-center gap-4 lg:grid-cols-[1fr_auto_1fr]",children:[n.jsx("a",{href:"#top",className:"min-w-0",children:n.jsx("span",{className:"serif block truncate",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.12em"},children:i.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center justify-center gap-10 lg:flex",children:c.map(d=>n.jsx("a",{href:d.href,className:`py-1 text-[length:var(--fs-sm)] font-light ${f}`,children:d.label},d.href))}),n.jsxs("div",{className:"flex items-center justify-end gap-2",children:[i.place.phone&&n.jsx("a",{href:`tel:${i.place.phone}`,className:`tap hidden items-center text-[length:var(--fs-sm)] font-light tabular-nums sm:inline-flex ${f}`,children:i.place.phone}),c.length>0&&n.jsxs("details",{className:"lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer list-none items-center justify-center px-2 text-[length:var(--fs-sm)] font-light marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:"메뉴"}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"border-line absolute inset-x-0 top-full border-b border-t",style:{backgroundColor:"var(--tpl-surface, #fff)",color:"var(--tpl-text)",textShadow:"none"},children:n.jsx("ul",{className:"divide-line shell divide-y",children:c.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center text-[length:var(--fs-sm)] font-light",children:d.label})},d.href))})})]})]})]})})}function i1(){var x,v,y,z;const i=le(),{place:c,site:o,narrative:u}=i,f=qp(),d=c.roadAddress??c.address,h=Ze(i)[0],p=wn(i),g=u.about[0]??u.summary;return n.jsx("footer",{className:"border-line paper w-full border-t pb-28 md:pb-16",style:{backgroundColor:"var(--tpl-surface-alt, #f5f5f4)",paddingTop:"var(--section-space)"},children:n.jsxs("div",{className:"shell",children:[n.jsxs("div",{className:"grid gap-10 md:grid-cols-3 md:gap-12",children:[n.jsxs("div",{className:"min-w-0",children:[n.jsx("p",{className:"serif",style:{fontSize:"var(--fs-lead)",fontWeight:300,letterSpacing:"0.1em"},children:c.name}),g&&n.jsx("p",{className:"text-muted measure mt-4 text-[length:var(--fs-sm)] leading-loose",children:g})]}),n.jsxs("dl",{className:"min-w-0 space-y-5",children:[d&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"주소"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:d})]}),c.phone&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"전화"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light tabular-nums",children:n.jsx("a",{href:`tel:${c.phone}`,className:"underline-offset-4 hover:underline",children:c.phone})})]}),c.email&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"이메일"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:`mailto:${c.email}`,className:"underline-offset-4 hover:underline",children:c.email})})]}),h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"예약"}),n.jsx("dd",{className:"mt-1 text-[length:var(--fs-sm)] font-light",children:n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"underline-offset-4 hover:underline",children:ks(h)})})]})]}),f.length>0&&n.jsx("nav",{"aria-label":"사이트 메뉴",className:"min-w-0",children:n.jsx("ul",{className:"space-y-3",children:f.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.href,className:"text-muted text-[length:var(--fs-sm)] font-light underline-offset-4 hover:underline",children:w.label})},w.href))})})]}),n.jsx("hr",{className:"border-line mt-12 border-t"}),p.length>0&&n.jsx("ul",{className:"mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted text-[length:var(--fs-xs)] font-light underline-offset-4 hover:underline",children:ks(w)})},w.url))}),n.jsxs("div",{className:"text-muted mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-center text-[length:var(--fs-xs)] font-light",children:[n.jsxs("span",{children:["상호 ",c.name]}),((x=c.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표 ",c.legal.representative]}),((v=c.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호 ",c.legal.businessRegistrationNumber]}),((y=c.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고 ",c.legal.mailOrderNumber]}),((z=c.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[c.legal.licenseLabel??"인허가번호"," ",c.legal.licenseNumber]}),n.jsxs("span",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",c.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:o.updatedAt,children:qs(o.updatedAt)})]})]})]})})}const c1=` +.oasi-page main > section:not([data-oasi]) { + background-color: transparent !important; + background-image: none !important; + border-color: transparent !important; + color: var(--tpl-text) !important; + padding-block: var(--oasi-gap) 0 !important; +} +.oasi-page main > section:not([data-oasi]) .shell { + max-width: none; + padding-inline: 0; +} +.oasi-page main > section#about > .shell > .grid { + display: block; +} +.oasi-page main > section#about > .shell > .grid > * + * { + margin-top: 2.5rem; +} +.oasi-page main > section#summary dl { + grid-template-columns: minmax(0, 1fr); +} + +/* + * 붙여넣기 아이템 카드 — 소식 · 영상 · 추천 일정 (2026-09-04, 사장님: "1,2,3안 다 맞추세요") + * + * ★ 이 카드들은 .panel 이 아니라 제 인라인 style 로 면을 그린다(items/common.tsx 의 + * ITEM_CARD·ITEM_BORDER). 그래서 위 .panel 규칙이 안 닿았고, 섹션 띠를 다 지운 3안에서 + * **소식 카드만 갱지 결에 2px 테두리를 두른 상자로 혼자 서 있었다.** + * ★ w4-paper 의 결은 1안(레트로 갱지)의 것이다. 여기서는 결을 지우고 테두리를 실선 한 겹으로 + * 낮춘다. 상자를 아예 없애지는 않는다 — 전단 여섯 장이 경계 없이 붙으면 어디까지가 한 장인지 + * 안 보인다. 면은 빼고 테두리만 남기는 게 이 안의 타협점이다. + * ★ 배경은 transparent 로 두지 않는다. 사진 없는 공지 카드가 종이에 바로 얹히면 + * 본문과 구분이 사라진다 — 아주 옅은 틴트 한 겹만 남긴다. + * ★ 영상은 뺐다 (2026-09-04, 사장님: "영상 border 안 예쁘다") + * 영상 카드는 상자가 아니라 **표지 사진 한 장**이고, 쇼츠라 9:16 세로다. li 에 틴트를 깔면 + * 영상보다 넓은 회색 판이 뒤에 깔려 액자처럼 보인다. 사진은 제 테두리가 곧 경계다. + */ +.oasi-page section#event li, +.oasi-page section#itinerary article { + background-image: none !important; + background-color: color-mix(in oklab, currentcolor 3%, transparent) !important; + border-width: 1px !important; + box-shadow: none !important; +} + +.oasi-page .panel, +.oasi-page .panel-sunken { + background-color: transparent; + border-width: 0; + border-radius: 0; + box-shadow: none; +} + +.oasi-page main > section#faq > .shell > div:last-of-type { + row-gap: 0; + column-gap: 2.5rem; +} +.oasi-page main > section#faq details { + margin-block: 0; + border-top: 1px solid var(--site-line); +} +.oasi-page main > section#faq summary { + padding-inline: 0; +} +.oasi-page main > section#faq summary > span:first-child { + width: 1.125rem; +} +.oasi-page main > section#faq summary + div { + border-top-width: 0; + padding-inline: 1.875rem 0; + padding-top: 0.5rem; +} + +.oasi-page main > section#info dl.panel > div { + padding-inline: 0; +} +.oasi-page main > section#info dl > div.panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} + +.oasi-page main > section#location .panel { + border-top: 1px solid var(--site-line); + padding-inline: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):first-child { + padding-left: 0; +} +.oasi-page main > section#location :is(thead, tbody) :is(th, td):last-child { + padding-right: 0; +} + +.oasi-page main > section#weather .panel { + padding-inline: 0; +} + +.oasi-page main > section#guide .panel > span:last-child { + padding-inline: 0; +} +@media (min-width: 40rem) { + .oasi-page main > section#festival .panel > span:last-child { + padding-inline: 0; + } +} +`,r1={"--oasi-gap":"clamp(3.5rem, 7vw, var(--tpl-section-space, 4rem))",backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"};function o1({children:i}){var v,y,z,w;const c=le(),{place:o}=c,u=ft(c),f=Ze(c)[0],d=wn(c),h=[{label:"처음",href:"#top",show:!0},{label:"소개",href:"#about",show:Kt(c,"intro")},{label:u.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(N=>N.show),p=[...Oa(c).map(N=>`${N.label} ${N.value}`),...c.theme.sections.filter(N=>{var T;return N.enabled&&((T=N.name)==null?void 0:T.trim())}).map(N=>N.name.trim())].slice(0,3),g=c.narrative.tagline??c.narrative.heroSubline,x=o.roadAddress??o.address;return n.jsxs("div",{className:"oasi-page flex min-h-screen w-full flex-col",style:r1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:c1}}),n.jsxs("div",{className:"mx-auto flex w-full max-w-[72rem] flex-col gap-10 px-5 pb-16 pt-10 lg:flex-row lg:gap-14 lg:px-10 lg:pb-24 lg:pt-16",children:[n.jsxs("header",{className:"lg:sticky lg:top-12 lg:h-fit lg:w-[18.75rem] lg:shrink-0",children:[n.jsxs("a",{href:"#top",className:"block",children:[n.jsx("span",{className:"serif block leading-none",style:{fontSize:"var(--fs-display)",fontWeight:300,letterSpacing:"0.14em"},children:o.name}),o.englishName&&n.jsx("span",{className:"text-muted mt-3 block text-[length:var(--fs-xs)] tracking-[0.24em]",children:o.englishName})]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-12 lg:mt-24",children:n.jsx("ul",{className:"flex flex-wrap items-center gap-x-6 gap-y-1 lg:block",children:h.map((N,T)=>n.jsx("li",{children:n.jsx("a",{href:N.href,"aria-current":T===0?"true":void 0,className:`tap inline-flex items-center text-[length:var(--fs-sm)] transition-opacity hover:opacity-100 ${T===0?"":"text-muted"}`,children:N.label})},N.label))})}),f&&n.jsx("a",{href:f.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]",style:{color:"var(--color-brand)"},children:ht(f)})]}),n.jsxs("div",{className:"flex min-w-0 flex-1 flex-col",children:[(p.length>0||g)&&n.jsxs("div",{className:"border-line flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6",children:[p.length>0&&n.jsx("ul",{className:"text-muted flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)]",children:p.map((N,T)=>n.jsxs("li",{className:"flex items-center gap-4",children:[T>0&&n.jsx("span",{"aria-hidden":!0,className:"opacity-40",children:"|"}),n.jsx("span",{children:N})]},N))}),g&&n.jsx("p",{className:"text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right",children:g})]}),i]})]}),n.jsx("footer",{className:"border-line mt-auto w-full border-t",children:n.jsxs("div",{className:"mx-auto grid w-full max-w-[72rem] grid-cols-1 gap-8 px-5 pb-28 pt-10 text-[length:var(--fs-xs)] leading-[1.9] sm:grid-cols-3 lg:px-10 lg:pb-14",children:[n.jsxs("p",{children:["[",o.name,"]"]}),d.length>0?n.jsx("ul",{className:"space-y-1",children:d.map(N=>n.jsx("li",{children:n.jsx("a",{href:N.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-[5px]",children:ks(N)})},N.url))}):n.jsx("span",{}),n.jsxs("div",{className:"text-muted space-y-1 sm:text-right",children:[x&&n.jsx("p",{children:x}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-[5px]",children:o.phone})}),o.email&&n.jsx("p",{children:o.email}),((v=o.legal)==null?void 0:v.representative)&&n.jsxs("p",{children:["대표 ",o.legal.representative]}),((y=o.legal)==null?void 0:y.businessRegistrationNumber)&&n.jsxs("p",{children:["사업자등록번호 ",o.legal.businessRegistrationNumber]}),((z=o.legal)==null?void 0:z.mailOrderNumber)&&n.jsxs("p",{children:["통신판매업신고 ",o.legal.mailOrderNumber]}),((w=o.legal)==null?void 0:w.licenseNumber)&&n.jsxs("p",{children:[o.legal.licenseLabel??"인허가번호"," ",o.legal.licenseNumber]}),n.jsxs("p",{className:"byline",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:c.site.updatedAt,children:qs(c.site.updatedAt)})]})]})]})}),n.jsx(_n,{})]})}function u1({children:i}){var z,w,N,T;const c=le(),{place:o,narrative:u,site:f}=c,d=ft(c),h=En(c),p=Oa(c),g=Ze(c)[0],x=c.links.filter(D=>D.confirmed),v=o.roadAddress??o.address,y=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:d.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변 정보",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"자주 묻는 질문",href:"#faq",show:c.faqs.length>0}].filter(D=>D.show);return n.jsxs("div",{className:"flex min-h-screen w-full flex-col lg:flex-row",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("aside",{className:"w-full shrink-0 px-6 pb-8 pt-8 lg:sticky lg:top-0 lg:h-screen lg:w-[260px] lg:overflow-y-auto lg:px-7 lg:py-10",style:{backgroundColor:"var(--tpl-bg)"},children:n.jsxs("div",{className:"flex h-full min-w-0 flex-col",children:[n.jsx("a",{href:"#top",className:"block min-w-0",children:n.jsx("h1",{className:"serif min-w-0 break-keep font-black",style:{fontSize:"var(--fs-h2)",letterSpacing:"0.12em",lineHeight:1.25},children:o.name})}),(u.tagline??u.heroSubline)&&n.jsx("p",{className:"text-muted mt-3 text-[length:var(--fs-sm)]",style:{lineHeight:1.7},children:u.tagline??u.heroSubline}),(h||p.length>0)&&n.jsxs("dl",{className:"mt-7 space-y-2.5",children:[h&&n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:"최저가"}),n.jsx("dd",{className:"text-[length:var(--fs-lead)] font-bold tabular-nums",children:h})]}),p.map(D=>n.jsxs("div",{children:[n.jsx("dt",{className:"label",children:D.label}),n.jsx("dd",{className:"text-[length:var(--fs-sm)] font-semibold",children:D.value})]},D.label))]}),g&&n.jsx("a",{href:g.url,target:"_blank",rel:"noopener noreferrer",className:"tap mt-5 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90",style:{backgroundColor:"var(--color-brand)",color:"var(--tpl-bg)"},children:ht(g)}),o.phone&&n.jsxs("a",{href:`tel:${o.phone}`,className:"tap mt-2 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold",style:{backgroundColor:"var(--tpl-surface-alt)",color:"var(--tpl-text)"},children:["전화 ",o.phone]}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"mt-10",children:n.jsx("ul",{className:"space-y-0.5",children:y.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.href,className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-sm)] transition-colors hover:text-current",children:D.label})},D.label))})}),x.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"mt-12",children:n.jsx("ul",{className:"space-y-0.5",children:x.map(D=>n.jsx("li",{children:n.jsx("a",{href:D.url,target:"_blank",rel:"noopener noreferrer",className:"text-muted flex min-h-[2rem] items-center text-[length:var(--fs-xs)] underline-offset-4 transition-colors hover:text-current hover:underline",children:ml(D)})},D.url))})}),n.jsxs("div",{className:"text-muted mt-14 space-y-1 pb-2 text-[length:var(--fs-xs)] lg:mt-auto lg:pt-14",children:[v&&n.jsx("p",{className:"break-keep",children:v}),o.phone&&n.jsx("p",{children:o.phone}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline-offset-2 hover:underline",children:o.email})}),n.jsxs("div",{className:"flex flex-wrap gap-x-2 gap-y-0.5 pt-3",children:[n.jsxs("span",{children:["상호: ",o.name]}),((z=o.legal)==null?void 0:z.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((w=o.legal)==null?void 0:w.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((N=o.legal)==null?void 0:N.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((T=o.legal)==null?void 0:T.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline pt-1",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:f.updatedAt,children:qs(f.updatedAt)})]})]})]})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col pb-24 lg:pb-0",children:i}),n.jsx(_n,{})]})}const f1=` +.pastel-page > main > section:not([data-pastel]) { + border-color: transparent; + background-image: none; + color: var(--pastel-ink) !important; +} +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+1) { background-color: var(--pastel-1) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+2) { background-color: var(--pastel-soft) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n+3) { background-color: var(--pastel-2) !important; } +.pastel-page > main > section:not([data-pastel]):nth-of-type(4n) { background-color: var(--pastel-3) !important; } +`,d1={"--pastel-1":"var(--tpl-card)","--pastel-2":"var(--tpl-accent)","--pastel-3":"var(--tpl-secondary)","--pastel-soft":"var(--tpl-bg)","--pastel-ink":"var(--tpl-text)","--section-space":"clamp(2.75rem, 8vw, calc(var(--tpl-section-space, 4rem) * 1.4))",backgroundColor:"var(--pastel-soft)",color:"var(--pastel-ink)"};function m1({children:i}){const c=le(),o=ft(c),u=wn(c),f=[{label:"소개",href:"#about",show:Kt(c,"intro")},{label:o.label,href:"#units",show:c.units.length>0},{label:"이용 정보",href:"#info",show:!0},{label:"주변",href:"#guide",show:Kt(c,"local")},{label:"오시는 길",href:"#location",show:!0},{label:"FAQ",href:"#faq",show:c.faqs.length>0}].filter(d=>d.show);return n.jsxs("div",{className:"pastel-page flex min-h-screen w-full flex-col",style:d1,children:[n.jsx("style",{dangerouslySetInnerHTML:{__html:f1}}),n.jsx("header",{className:"sticky top-0 z-40 w-full",style:{backgroundColor:"color-mix(in srgb, var(--tpl-surface, #fff) 92%, transparent)"},children:n.jsxs("div",{className:"shell flex h-16 items-center justify-between gap-3",children:[n.jsx("a",{href:"#top",className:"flex min-w-0 items-center gap-2",children:n.jsx("span",{className:"serif truncate italic",style:{fontSize:"var(--fs-lead)",fontWeight:800,display:"inline-block",transform:"rotate(-1.5deg)"},children:c.place.name})}),n.jsx("nav",{"aria-label":"주요 메뉴",className:"hidden items-center gap-8 lg:flex",children:f.map(d=>n.jsx("a",{href:d.href,className:"py-1 text-[length:var(--fs-xs)] font-bold tracking-[0.2em] opacity-70 transition-opacity hover:opacity-100",children:d.label},d.label))}),n.jsxs("div",{className:"flex shrink-0 items-center gap-1",children:[c.place.phone&&n.jsx("a",{href:`tel:${c.place.phone}`,"aria-label":"전화",className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-1)"},children:n.jsx(At,{className:"size-4"})}),u.map(d=>n.jsx("a",{href:d.url,target:"_blank",rel:"noopener noreferrer","aria-label":ml(d),className:"tap flex items-center justify-center rounded-full",style:{backgroundColor:"var(--pastel-2)"},children:/instagram/i.test(d.url)?n.jsx(Ub,{className:"size-4"}):n.jsx(Io,{className:"size-4"})},d.url)),n.jsxs("details",{className:"relative lg:hidden",children:[n.jsx("summary",{className:"tap flex cursor-pointer items-center justify-center rounded-full marker:content-none [&::-webkit-details-marker]:hidden","aria-label":"메뉴 열기",children:n.jsx(hp,{className:"size-5"})}),n.jsx("nav",{"aria-label":"전체 메뉴",className:"absolute right-0 top-[calc(100%+0.5rem)] z-50 w-48 overflow-hidden rounded-2xl",style:{backgroundColor:"var(--tpl-surface, #fff)",boxShadow:"0 10px 30px rgb(0 0 0 / 0.12)"},children:n.jsx("ul",{children:f.map(d=>n.jsx("li",{children:n.jsx("a",{href:d.href,className:"tap flex items-center px-4 text-[length:var(--fs-sm)] font-bold tracking-[0.08em]",children:d.label})},d.label))})})]})]})]})}),i,n.jsx("div",{style:{"--tpl-inverse":"var(--pastel-3)","--tpl-bg":"var(--pastel-ink)"},children:n.jsx(Op,{})}),n.jsx(_n,{})]})}function h1({children:i}){var x,v,y,z;const c=le(),{place:o,site:u}=c,f=Ep(),d=p1(f),h=Ze(c)[0],p=c.links.filter(w=>w.confirmed),g=o.roadAddress??o.address;return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",style:{backgroundColor:"var(--tpl-bg)",color:"var(--tpl-text)"},children:[n.jsx("header",{className:"sticky top-0 z-40 w-full border-b-2 backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--tpl-bg) 90%, transparent)",borderColor:"var(--tpl-text)"},children:n.jsxs("div",{className:"flex h-14 items-center justify-between gap-4 px-4 sm:px-6 xl:px-8",children:[n.jsx("a",{href:"#top",className:"serif min-w-0 truncate text-[length:var(--fs-lead)] font-extrabold",children:o.name}),n.jsxs("div",{className:"flex shrink-0 items-center gap-4 text-[length:var(--fs-sm)]",children:[o.phone&&n.jsx("a",{href:`tel:${o.phone}`,className:"tap inline-flex items-center underline underline-offset-4",children:o.phone}),h&&n.jsx("a",{href:h.url,target:"_blank",rel:"noopener noreferrer",className:"tap inline-flex items-center font-bold underline underline-offset-4",children:ht(h)})]})]})}),n.jsxs("div",{className:"flex w-full flex-1",children:[f.length>0&&n.jsx("aside",{className:"border-line sticky top-14 hidden h-[calc(100vh-3.5rem)] w-44 shrink-0 self-start border-r xl:block",style:{backgroundImage:Os},children:n.jsx("nav",{"aria-label":"차례",className:"flex h-full flex-col justify-center gap-0.5 py-8 pl-6 pr-3",children:f.map(w=>{const N=w.anchor===d;return n.jsxs("a",{href:`#${w.anchor}`,"aria-current":N?"true":void 0,className:"flex items-baseline gap-2 py-2 text-[length:var(--fs-sm)] leading-tight motion-safe:transition-opacity",style:{opacity:N?1:.35},children:[n.jsx("span",{className:"shrink-0 text-[length:var(--fs-xs)] font-bold tabular-nums",children:w.no}),n.jsx("span",{className:"truncate",style:{fontWeight:N?700:400},children:w.label})]},w.anchor)})})}),n.jsx("div",{className:"flex min-w-0 flex-1 flex-col",children:i})]}),n.jsx("footer",{className:"w-full pb-28 pt-12 md:pb-16",style:{backgroundColor:"var(--tpl-inverse)",color:"var(--tpl-bg)",backgroundImage:Os},children:n.jsxs("div",{className:"shell",children:[n.jsx("div",{"aria-hidden":!0,className:"h-0.5 w-full bg-current opacity-70"}),n.jsxs("div",{className:"grid grid-cols-1 gap-8 pt-8 md:grid-cols-12",children:[n.jsxs("div",{className:"md:col-span-5",children:[n.jsx("p",{className:"serif",style:{fontSize:"clamp(1.5rem, 4vw, 2.5rem)",fontWeight:800,lineHeight:1.05},children:o.name}),o.englishName&&n.jsx("p",{className:"mt-2 text-[length:var(--fs-xs)] uppercase tracking-[0.18em] opacity-55",children:o.englishName})]}),n.jsxs("address",{className:"space-y-2 font-serif text-[length:var(--fs-sm)] not-italic opacity-80 md:col-span-4",children:[g&&n.jsx("p",{children:g}),o.phone&&n.jsx("p",{children:n.jsx("a",{href:`tel:${o.phone}`,className:"underline underline-offset-4",children:o.phone})}),o.email&&n.jsx("p",{children:n.jsx("a",{href:`mailto:${o.email}`,className:"underline underline-offset-4",children:o.email})})]}),p.length>0&&n.jsx("nav",{"aria-label":"공식 채널",className:"md:col-span-3",children:n.jsx("ul",{className:"space-y-1.5 text-[length:var(--fs-sm)]",children:p.map(w=>n.jsx("li",{children:n.jsx("a",{href:w.url,target:"_blank",rel:"noopener noreferrer",className:"underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100",children:ml(w)})},w.url))})})]}),n.jsxs("div",{className:"mt-10 flex flex-col gap-2 border-t border-current/20 pt-5 text-[length:var(--fs-xs)] opacity-55 sm:flex-row sm:items-center sm:justify-between",children:[n.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1",children:[n.jsxs("span",{children:["상호: ",o.name]}),((x=o.legal)==null?void 0:x.representative)&&n.jsxs("span",{children:["대표: ",o.legal.representative]}),((v=o.legal)==null?void 0:v.businessRegistrationNumber)&&n.jsxs("span",{children:["사업자등록번호: ",o.legal.businessRegistrationNumber]}),((y=o.legal)==null?void 0:y.mailOrderNumber)&&n.jsxs("span",{children:["통신판매업신고: ",o.legal.mailOrderNumber]}),((z=o.legal)==null?void 0:z.licenseNumber)&&n.jsxs("span",{children:[o.legal.licenseLabel??"인허가번호",": ",o.legal.licenseNumber]})]}),n.jsxs("p",{className:"byline shrink-0",children:[n.jsxs("span",{className:"author",children:["작성·운영 ",o.name]})," · 최종 업데이트: ",n.jsx("time",{dateTime:u.updatedAt,children:qs(u.updatedAt)})]})]})]})}),n.jsx(_n,{})]})}function p1(i){const c=i.map(f=>f.anchor).join(","),[o,u]=q.useState("");return q.useEffect(()=>{if(typeof IntersectionObserver>"u")return;const f=c.split(",").map(h=>document.getElementById(h)).filter(h=>h!==null);if(f.length===0)return;const d=new IntersectionObserver(h=>{const p=h.find(g=>g.isIntersecting);p&&u(p.target.id)},{rootMargin:"-45% 0px -50% 0px"});return f.forEach(h=>d.observe(h)),()=>d.disconnect()},[c]),o}function x1(){const i=le(),c=i.place.category===Ma.LODGING,o={intro:ay,info:ny,rooms:Lo,menu:Lo,programs:Lo,booking:c?Fh:py,space:jy,inquiry:Ny,exhibition:Sy,photos:Oy,festival:wy,local:_y,weather:ky,map:ap,faq:Dy,...t1},u=new Set;return n.jsxs("main",{className:"w-full flex-1",children:[n.jsx(pv,{}),!Kt(i,"intro")&&n.jsx(Hh,{}),i.theme.sections.filter(f=>f.enabled&&f.id!=="hero").map(f=>{const d=o[f.id];if(!d)return null;const h=d.name;return u.has(h)?null:(u.add(h),n.jsxs("div",{"data-editor-id":f.id,style:{display:"contents"},children:[n.jsx(d,{}),f.id==="intro"&&n.jsx(Hh,{})]},f.id))}),!Kt(i,"map")&&n.jsx(ap,{}),c&&!W0(i,"booking")&&n.jsx(Fh,{})]})}function Wo({payload:i}){const c=zp(i.theme.templateId),o=c==="reservation"?l1:c==="oasi"?o1:c==="studio"?u1:c==="pastel"?m1:c==="editorial"?h1:g1;return n.jsx(n0,{payload:i,children:n.jsx(o,{children:n.jsx(x1,{})})})}function g1({children:i}){return n.jsxs("div",{className:"flex min-h-screen w-full flex-col",children:[n.jsx(F0,{}),n.jsx("div",{className:"flex w-full flex-1 flex-col",children:i}),n.jsx(Op,{}),n.jsx(_n,{})]})}function b1(i){const{colors:c,look:o}=i.theme,u=w0(c),f={"--tpl-primary":c.primary,"--tpl-secondary":c.secondary,"--tpl-bg":c.bg,"--tpl-card":c.card,"--tpl-text":c.text,"--tpl-accent":c.accent,"--tpl-surface":u.surface,"--tpl-surface-alt":u.surfaceAlt,"--tpl-inverse":u.inverse,"--tpl-border":u.border};if(o){const d=p=>p&&!/[<>{};]/.test(p)?p:void 0,h=[["--tpl-font-heading",d(o.fontHeading)],["--tpl-font-body",d(o.fontBody)],["--tpl-heading-tracking",d(o.headingTracking)],["--tpl-heading-weight",d(o.headingWeight)],["--tpl-section-space",d(o.sectionSpace)],["--tpl-border-width",d(o.borderWidth)],["--tpl-radius",d(o.radius)],["--tpl-shadow",d(o.shadow)],["--tpl-texture",d(o.texture)]];for(const[p,g]of h)g&&(f[p]=g)}return f}const v1=[[/Noto Sans KR/i,"family=Noto+Sans+KR:wght@300..900"],[/Noto Serif KR/i,"family=Noto+Serif+KR:wght@300..700"],[/Gugi/i,"family=Gugi"],[/Gowun Batang/i,"family=Gowun+Batang:wght@400;700"],[/Nanum Pen Script/i,"family=Nanum+Pen+Script"]];function y1(i){var u,f;const c=["Noto Sans KR","Noto Serif KR",((u=i.theme.look)==null?void 0:u.fontHeading)??"",((f=i.theme.look)==null?void 0:f.fontBody)??""].join(" ");return`https://fonts.googleapis.com/css2?${v1.filter(([d])=>d.test(c)).map(([,d])=>d).join("&")}&display=swap`}const oc=document.getElementById("root"),op=window.__SITE_PAYLOAD__;async function j1(i){const c=(()=>{try{return localStorage.getItem("o2o-web4ai.accessToken")}catch{return null}})(),o=await fetch(`/v1/place/${i}/site/preview`,{headers:c?{Authorization:`Bearer ${c}`}:{}});if(!o.ok)throw new Error(`HTTP ${o.status}`);const u=await o.json();for(const[d,h]of Object.entries(b1(u)))document.documentElement.style.setProperty(d,h);const f=document.createElement("link");f.rel="stylesheet",f.href=y1(u),document.head.appendChild(f),await new Promise(d=>{f.addEventListener("load",()=>d(),{once:!0}),f.addEventListener("error",()=>d(),{once:!0}),setTimeout(d,2500)});try{await document.fonts.ready}catch{}Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:u})}))}const up=new URLSearchParams(window.location.search).get("placeId");op?Qo.hydrateRoot(oc,n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:op})})):up?j1(up).catch(i=>{oc.textContent=`미리보기를 불러오지 못했습니다 — ${i instanceof Error?i.message:String(i)}`}):tb(async()=>{const{MOONLIGHT_STAY_PAYLOAD:i}=await import("./moonlight-stay-C8soi6-l.js");return{MOONLIGHT_STAY_PAYLOAD:i}},[]).then(({MOONLIGHT_STAY_PAYLOAD:i})=>{Qo.createRoot(oc).render(n.jsx(q.StrictMode,{children:n.jsx(Wo,{payload:i})}))});export{Ah as F,bt as L,Ma as P,N1 as S,S1 as a}; diff --git a/solution/site/scripts/mockup/vendor/retired/index-DQreaXOf.css b/solution/site/scripts/mockup/vendor/retired/index-DQreaXOf.css new file mode 100644 index 0000000..011b41a --- /dev/null +++ b/solution/site/scripts/mockup/vendor/retired/index-DQreaXOf.css @@ -0,0 +1 @@ +#w4d-mini{display:inline-flex;align-items:center;gap:2px;margin-right:6px;padding-right:8px;border-right:1px solid var(--tpl-border, #bcb49e)}#w4d-mini button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;color:var(--tpl-text, #1b1a15);background:transparent;border:0;border-radius:var(--tpl-radius, 0);cursor:pointer;opacity:.75}#w4d-mini button:hover{opacity:1;background:#1b1a150f}#w4d-mini svg{width:16px;height:16px}#w4d-now{max-width:150px;overflow:hidden;font-size:11.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis;color:var(--tpl-accent, #bf2f1b);padding-right:2px}#w4d-mini[data-playing="0"] #w4d-now{opacity:.55;font-weight:400}@media(max-width:860px){#w4d-now{display:none}}#w4d-tape{display:inline-flex;align-items:center;padding:0 4px 0 2px;color:var(--tpl-text, #1b1a15);opacity:.8}#w4d-tape svg{width:20px;height:20px}#w4d-mini[data-playing="1"] #w4d-tape{color:var(--tpl-accent, #bf2f1b);opacity:1}#w4d-mini[data-playing="1"] .w4d-reel{transform-box:fill-box;transform-origin:center;animation:w4d-reel 2.6s linear infinite}@keyframes w4d-reel{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.w4d-reel{animation:none!important}}#w4d-mini button[data-playing="1"]{color:var(--tpl-accent, #bf2f1b);opacity:1}@media(max-width:400px){#w4d-mini{margin-right:2px;padding-right:4px}#w4d-mini button{width:27px;height:27px}}#w4d-panel{position:fixed;z-index:45;width:344px;max-width:calc(100vw - 16px);max-height:min(64vh,520px);display:flex;flex-direction:column;overflow:hidden;font-family:var(--tpl-font-body, serif);color:var(--tpl-text, #1b1a15);background:var(--tpl-card, #efe7d3);border:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);box-shadow:var(--tpl-shadow, 4px 4px 0 rgb(27 26 21 / .16))}#w4d-panel[hidden]{display:none}@media(max-width:640px){#w4d-panel{width:auto}}#w4d-panel .w4d-head{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding:11px 8px 11px 14px;border-bottom:var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15)}.w4d-head-t{font-size:12px;font-weight:700;letter-spacing:.06em}.w4d-head-n{flex:1 1 auto;font-size:11px;opacity:.5}#w4d-panel .w4d-head button{display:inline-flex;width:28px;height:28px;flex:0 0 auto;align-items:center;justify-content:center;color:inherit;background:transparent;border:0;cursor:pointer;opacity:.6}#w4d-panel .w4d-head button:hover{opacity:1}#w4d-panel .w4d-head svg{width:14px;height:14px}#w4d-panel ol{flex:1 1 auto;margin:0;padding:4px 0;overflow-y:auto;list-style:none;-webkit-overflow-scrolling:touch}.w4d-item{display:flex;width:100%;min-height:52px;align-items:center;gap:11px;padding:8px 14px;color:inherit;background:transparent;border:0;font:inherit;text-align:left;cursor:pointer}.w4d-item+.w4d-item{border-top:1px dashed var(--tpl-border, #bcb49e)}.w4d-item:hover{background:#1b1a150d}.w4d-item[aria-current=true]{background:#1b1a150f}.w4d-item[aria-current=true] .w4d-t{color:var(--tpl-accent, #bf2f1b)}.w4d-mark{position:relative;flex:0 0 26px;height:26px}.w4d-disc-sm{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%;background:radial-gradient(circle at 50% 50%,var(--w4d-lbl, #bf2f1b) 0 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,rgb(255 255 255 / .12) 0 1px,transparent 1px 3px),#14110f}.w4d-disc-sm:after{content:"";position:absolute;top:45%;right:45%;bottom:45%;left:45%;border-radius:50%;background:var(--tpl-card, #efe7d3)}.w4d-eq{position:absolute;top:0;right:0;bottom:0;left:0;display:none;align-items:flex-end;justify-content:center;gap:3px;padding-bottom:4px}.w4d-eq i{width:3px;height:6px;background:var(--tpl-accent, #bf2f1b);animation:w4d-eq .9s ease-in-out infinite}.w4d-eq i:nth-child(2){animation-delay:.18s}.w4d-eq i:nth-child(3){animation-delay:.36s}@keyframes w4d-eq{0%,to{height:5px}50%{height:17px}}.w4d-item[data-playing="1"] .w4d-disc-sm{display:none}.w4d-item[data-playing="1"] .w4d-eq{display:flex}@media(prefers-reduced-motion:reduce){.w4d-eq i{animation:none;height:11px}}.w4d-info{min-width:0;flex:1 1 auto}.w4d-t{display:block;overflow:hidden;font-size:13.5px;font-weight:700;white-space:nowrap;text-overflow:ellipsis}.w4d-sub{display:block;margin-top:2px;overflow:hidden;font-size:11px;white-space:nowrap;text-overflow:ellipsis;opacity:.55}.w4d-time{flex:0 0 auto;font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}#w4d-lyrics-box{padding:10px 12px 14px;border-top:1px dashed var(--tpl-border, #bcb49e);white-space:pre-line;font-size:12px;line-height:1.9;overflow-y:auto}#w4d-lyrics-box[hidden]{display:none}.w4-sky{position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:none;overflow:hidden}.w4-sky[data-mood=비]:before{content:"";position:absolute;top:-20%;right:-10%;bottom:-20%;left:-10%;background-image:repeating-linear-gradient(102deg,transparent 0 9px,currentcolor 9px 10px,transparent 10px 22px);opacity:.13;animation:w4-rain .9s linear infinite}@keyframes w4-rain{to{transform:translate3d(-22px,108px,0)}}.w4-sky[data-mood=맑음]:before{content:"";position:absolute;right:6%;top:-40%;width:26rem;aspect-ratio:1;background-image:conic-gradient(from 0deg,currentcolor 0 2deg,transparent 2deg 30deg);opacity:.07;animation:w4-spin 70s linear infinite}.w4-sky[data-mood=맑음]:after{content:"";position:absolute;right:10%;top:-14%;width:15rem;aspect-ratio:1;border-radius:50%;background-image:radial-gradient(circle,currentcolor 0%,transparent 62%);opacity:.11;animation:w4-breathe 9s ease-in-out infinite}@keyframes w4-spin{to{transform:rotate(360deg)}}@keyframes w4-breathe{50%{opacity:.2}}.w4-sky[data-mood=흐림]:before,.w4-sky[data-mood=흐림]:after{content:"";position:absolute;top:-30%;right:-60%;bottom:-30%;left:-60%;background-image:radial-gradient(ellipse 22% 46% at 18% 42%,currentcolor 0%,transparent 70%),radial-gradient(ellipse 30% 40% at 62% 30%,currentcolor 0%,transparent 72%);opacity:.08;animation:w4-drift 44s linear infinite}.w4-sky[data-mood=흐림]:after{opacity:.05;animation-duration:78s;animation-direction:reverse}@keyframes w4-drift{to{transform:translate3d(28%,0,0)}}.w4-sky[data-mood=눈]:before{content:"";position:absolute;top:-20%;right:0;bottom:-20%;left:0;background-image:radial-gradient(circle at 12% 10%,currentcolor 1.4px,transparent 1.8px),radial-gradient(circle at 47% 34%,currentcolor 1.1px,transparent 1.5px),radial-gradient(circle at 78% 18%,currentcolor 1.6px,transparent 2px);background-size:9rem 9rem;opacity:.22;animation:w4-snow 14s linear infinite}@keyframes w4-snow{to{transform:translate3d(-2rem,9rem,0)}}@media(prefers-reduced-motion:reduce){.w4-sky:before,.w4-sky:after{animation:none!important}}.w4-paper{background-image:repeating-linear-gradient(0deg,color-mix(in oklab,currentcolor 4%,transparent) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,color-mix(in oklab,currentcolor 3%,transparent) 0 1px,transparent 1px 4px)}.w4-disc{background:radial-gradient(circle at 50% 50%,transparent 0 15.5%,var(--lbl) 15.5% 33%,transparent 33%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.4px,var(--w4-vinyl) 1.4px 3px),var(--w4-vinyl);box-shadow:inset 0 0 40px #0000008c}.w4-disc-sheen{background:conic-gradient(from 210deg,rgb(255 255 255 / 12%),transparent 22%,transparent 70%,rgb(255 255 255 / 7%))}.w4-spin{animation:w4-rev 2.2s linear infinite}@keyframes w4-rev{to{transform:rotate(360deg)}}.w4-disc-mini{background:radial-gradient(circle at 50% 50%,transparent 0 14%,var(--lbl) 14% 34%,transparent 34%),repeating-radial-gradient(circle at 50% 50%,color-mix(in oklab,var(--w4-vinyl) 88%,white) 0 1.2px,var(--w4-vinyl) 1.2px 2.6px),var(--w4-vinyl);box-shadow:0 3px 10px #00000059}.w4-perf{background:repeating-linear-gradient(90deg,transparent 0 8px,var(--tear) 8px 9px)}.w4-dash{border-top:1px dashed currentcolor}.w4-film-perf{background:repeating-linear-gradient(90deg,currentcolor 0 9px,transparent 9px 21px)}.w4-flip{perspective:900px}.w4-flip-inner{transform-style:preserve-3d;transition:transform .5s}.w4-flip-on .w4-flip-inner{transform:rotateY(180deg)}.w4-flip-face{backface-visibility:hidden}.w4-flip-back{transform:rotateY(180deg)}.w4-scroll{overflow-x:auto;scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch}.w4-scroll::-webkit-scrollbar{display:none}.w4-scroll[data-slider=on]{overflow-x:hidden;scroll-snap-type:none}.w4-scroll[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}@media(prefers-reduced-motion:reduce){.w4-spin{animation:none}.w4-flip-inner{transition:none}}@media print{.w4-flip,.w4-flip-inner{height:auto!important;transform:none!important}.w4-flip-face{position:static!important;backface-visibility:visible!important;transform:none!important}}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--font-serif:"Noto Serif KR", "Batang", "Times New Roman", serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-600:oklch(59.6% .145 163.225);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-950:oklch(14.1% .005 285.823);--color-stone-600:oklch(44.4% .011 73.639);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--leading-loose:2;--radius-sm:calc(var(--tpl-radius,.75rem) * .4);--radius-md:calc(var(--tpl-radius,.75rem) * .7);--radius-lg:var(--tpl-radius,.75rem);--radius-xl:calc(var(--tpl-radius,.75rem) * 1.35);--radius-2xl:calc(var(--tpl-radius,.75rem) * 1.7);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--default-mono-font-family:var(--font-mono);--color-brand:var(--tpl-primary);--color-surface:var(--tpl-bg);--color-surface-alt:var(--tpl-card);--color-ink:var(--tpl-text);--color-accent:var(--tpl-accent);--color-line:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-line:color-mix(in oklab, currentColor 14%, transparent)}}:root,:host{--color-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:root,:host{--color-muted:color-mix(in oklab, currentColor 62%, transparent)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-2{inset:calc(var(--spacing) * 2)}.inset-x-0{inset-inline:0}.inset-y-\[3px\]{inset-block:3px}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(100\%\+0\.5rem\)\]{top:calc(100% + .5rem)}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.-right-\[7px\]{right:-7px}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-0\.5{bottom:calc(var(--spacing) * .5)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-2\.5{bottom:calc(var(--spacing) * 2.5)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-5{bottom:calc(var(--spacing) * 5)}.-left-\[7px\]{left:-7px}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-full{left:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.col-span-12{grid-column:span 12/span 12}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-3{margin-block:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mt-auto{margin-top:auto}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-7{margin-bottom:calc(var(--spacing) * 7)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-3{margin-left:calc(var(--spacing) * -3)}.-ml-10{margin-left:calc(var(--spacing) * -10)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-\[9\]{-webkit-line-clamp:9;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.table{display:table}.aspect-3\/2{aspect-ratio:3/2}.aspect-3\/4{aspect-ratio:3/4}.aspect-4\/3{aspect-ratio:4/3}.aspect-16\/9{aspect-ratio:16/9}.aspect-16\/10{aspect-ratio:16/10}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[16\/9\]{aspect-ratio:16/9}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-\[17px\]{width:17px;height:17px}.size-\[54px\]{width:54px;height:54px}.size-\[76px\]{width:76px;height:76px}.size-\[132px\]{width:132px;height:132px}.size-\[220px\]{width:220px;height:220px}.size-full{width:100%;height:100%}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-9{height:calc(var(--spacing) * 9)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-48{height:calc(var(--spacing) * 48)}.h-\[1em\]{height:1em}.h-\[6px\]{height:6px}.h-\[22px\]{height:22px}.h-\[56px\]{height:56px}.h-\[260px\]{height:260px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[clamp\(15rem\,24vw\,21rem\)\]{height:clamp(15rem,24vw,21rem)}.h-\[clamp\(17rem\,27vw\,24rem\)\]{height:clamp(17rem,27vw,24rem)}.h-auto{height:auto}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[26rem\]{max-height:26rem}.max-h-\[46vh\]{max-height:46vh}.max-h-\[72vh\]{max-height:72vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[88vh\]{max-height:88vh}.max-h-full{max-height:100%}.min-h-\[2rem\]{min-height:2rem}.min-h-\[16rem\]{min-height:16rem}.min-h-\[420px\]{min-height:420px}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-\[3px\]{width:3px}.w-\[44px\]{width:44px}.w-\[62px\]{width:62px}.w-\[110px\]{width:110px}.w-\[168px\]{width:168px}.w-\[210px\]{width:210px}.w-\[228px\]{width:228px}.w-\[236px\]{width:236px}.w-\[254px\]{width:254px}.w-\[262px\]{width:262px}.w-\[280px\]{width:280px}.w-\[304px\]{width:304px}.w-\[320px\]{width:320px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[34rem\]{max-width:34rem}.max-w-\[72rem\]{max-width:72rem}.max-w-\[880px\]{max-width:880px}.max-w-full{max-width:100%}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-\[22px\]{min-width:22px}.min-w-\[30rem\]{min-width:30rem}.min-w-\[34rem\]{min-width:34rem}.flex-1{flex:1}.flex-\[0_0_88\%\]{flex:0 0 88%}.flex-\[1\.4\]{flex:1.4}.shrink-0{flex-shrink:0}.grow-0{flex-grow:0}.basis-40{flex-basis:calc(var(--spacing) * 40)}.basis-\[76\%\]{flex-basis:76%}.basis-\[86\%\]{flex-basis:86%}.border-collapse{border-collapse:collapse}.origin-right{transform-origin:100%}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-px{--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-6{rotate:-6deg}.-rotate-12{rotate:-12deg}.rotate-6{rotate:6deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-start{scroll-snap-align:start}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-none{list-style-type:none}.columns-1{columns:1}.columns-2{columns:2}.break-inside-avoid{break-inside:avoid}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[46px_minmax\(0\,1fr\)\]{grid-template-columns:46px minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-7{gap:calc(var(--spacing) * 7)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-14{gap:calc(var(--spacing) * 14)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-20>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 20) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 20) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-x-10{column-gap:calc(var(--spacing) * 10)}.gap-x-12{column-gap:calc(var(--spacing) * 12)}.gap-y-0{row-gap:0}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.gap-y-5{row-gap:calc(var(--spacing) * 5)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.gap-y-14{row-gap:calc(var(--spacing) * 14)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-black\/5>:not(:last-child)){border-color:#0000000d}@supports (color:color-mix(in lab,red,red)){:where(.divide-black\/5>:not(:last-child)){border-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}:where(.divide-black\/8>:not(:last-child)){border-color:#00000014}@supports (color:color-mix(in lab,red,red)){:where(.divide-black\/8>:not(:last-child)){border-color:color-mix(in oklab,var(--color-black) 8%,transparent)}}:where(.divide-line>:not(:last-child)){border-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.divide-line>:not(:last-child)){border-color:color-mix(in oklab,currentColor 14%,transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.7)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--tpl-radius,.75rem)}.rounded-md{border-radius:calc(var(--tpl-radius,.75rem) * .7)}.rounded-sm{border-radius:calc(var(--tpl-radius,.75rem) * .4)}.rounded-xl{border-radius:calc(var(--tpl-radius,.75rem) * 1.35)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.border-black\/5{border-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab,red,red)){.border-black\/8{border-color:color-mix(in oklab,var(--color-black) 8%,transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab,red,red)){.border-black\/10{border-color:color-mix(in oklab,var(--color-black) 10%,transparent)}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/15{border-color:color-mix(in oklab,currentcolor 15%,transparent)}}.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/20{border-color:color-mix(in oklab,currentcolor 20%,transparent)}}.border-current\/25{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/25{border-color:color-mix(in oklab,currentcolor 25%,transparent)}}.border-current\/60{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/60{border-color:color-mix(in oklab,currentcolor 60%,transparent)}}.border-current\/70{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/70{border-color:color-mix(in oklab,currentcolor 70%,transparent)}}.border-line{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-line{border-color:color-mix(in oklab,currentColor 14%,transparent)}}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.border-white\/20{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.bg-\[\#03C75A\]{background-color:#03c75a}.bg-\[\#0064FF\]{background-color:#0064ff}.bg-\[\#FEE500\]{background-color:#fee500}.bg-\[color-mix\(in_srgb\,var\(--color-surface\)_92\%\,transparent\)\]{background-color:var(--color-surface)}@supports (color:color-mix(in lab,red,red)){.bg-\[color-mix\(in_srgb\,var\(--color-surface\)_92\%\,transparent\)\]{background-color:color-mix(in srgb,var(--color-surface) 92%,transparent)}}.bg-amber-400{background-color:var(--color-amber-400)}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.bg-black\/5{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab,red,red)){.bg-black\/20{background-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab,red,red)){.bg-black\/45{background-color:color-mix(in oklab,var(--color-black) 45%,transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab,red,red)){.bg-black\/55{background-color:color-mix(in oklab,var(--color-black) 55%,transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\/70{background-color:color-mix(in oklab,var(--color-black) 70%,transparent)}}.bg-black\/90{background-color:#000000e6}@supports (color:color-mix(in lab,red,red)){.bg-black\/90{background-color:color-mix(in oklab,var(--color-black) 90%,transparent)}}.bg-black\/92{background-color:#000000eb}@supports (color:color-mix(in lab,red,red)){.bg-black\/92{background-color:color-mix(in oklab,var(--color-black) 92%,transparent)}}.bg-current,.bg-current\/5{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/5{background-color:color-mix(in oklab,currentcolor 5%,transparent)}}.bg-current\/8{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/8{background-color:color-mix(in oklab,currentcolor 8%,transparent)}}.bg-current\/15{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.bg-current\/15{background-color:color-mix(in oklab,currentcolor 15%,transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab,red,red)){.bg-white\/15{background-color:color-mix(in oklab,var(--color-white) 15%,transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-zinc-950{--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-zinc-950\/60{--tw-gradient-via:#09090b99}@supports (color:color-mix(in lab,red,red)){.via-zinc-950\/60{--tw-gradient-via:color-mix(in oklab, var(--color-zinc-950) 60%, transparent)}}.via-zinc-950\/60{--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-zinc-950\/30{--tw-gradient-to:#09090b4d}@supports (color:color-mix(in lab,red,red)){.to-zinc-950\/30{--tw-gradient-to:color-mix(in oklab, var(--color-zinc-950) 30%, transparent)}}.to-zinc-950\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-center{object-position:center}.p-0{padding:0}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-9{padding-inline:calc(var(--spacing) * 9)}.px-\[7\%\]{padding-inline:7%}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-px{padding-block:1px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pt-\[var\(--oasi-gap\)\]{padding-top:var(--oasi-gap)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-24{padding-bottom:calc(var(--spacing) * 24)}.pb-28{padding-bottom:calc(var(--spacing) * 28)}.pb-56{padding-bottom:calc(var(--spacing) * 56)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[calc\(3px\+0\.625rem\)\]{padding-left:calc(3px + .625rem)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-serif{font-family:Noto Serif KR,Batang,Times New Roman,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[17px\]{font-size:17px}.text-\[length\:var\(--fs-body\)\]{font-size:var(--fs-body)}.text-\[length\:var\(--fs-h2\)\]{font-size:var(--fs-h2)}.text-\[length\:var\(--fs-h3\)\]{font-size:var(--fs-h3)}.text-\[length\:var\(--fs-lead\)\]{font-size:var(--fs-lead)}.text-\[length\:var\(--fs-sm\)\]{font-size:var(--fs-sm)}.text-\[length\:var\(--fs-xs\)\]{font-size:var(--fs-xs)}.leading-\[1\.8\]{--tw-leading:1.8;line-height:1.8}.leading-\[1\.9\]{--tw-leading:1.9;line-height:1.9}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.1em\]{--tw-tracking:.1em;letter-spacing:.1em}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.24em\]{--tw-tracking:.24em;letter-spacing:.24em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.\!text-current{color:currentColor!important}.text-\[\#191600\]{color:#191600}.text-amber-700{color:var(--color-amber-700)}.text-current\/55{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-current\/55{color:color-mix(in oklab,currentcolor 55%,transparent)}}.text-muted{color:currentColor}@supports (color:color-mix(in lab,red,red)){.text-muted{color:color-mix(in oklab,currentColor 62%,transparent)}}.text-stone-600{color:var(--color-stone-600)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\/70{color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-950{color:var(--color-zinc-950)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.decoration-black\/20{text-decoration-color:#0003}@supports (color:color-mix(in lab,red,red)){.decoration-black\/20{-webkit-text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent);text-decoration-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.underline-offset-\[5px\]{text-underline-offset:5px}.underline-offset-\[6px\]{text-underline-offset:6px}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-55{opacity:.55}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-95{opacity:.95}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.group-open\:rotate-45:is(:where(.group):is([open],:popover-open,:open) *){rotate:45deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media(hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.marker\:content-none ::marker{--tw-content:none;content:none}.marker\:content-none::marker{--tw-content:none;content:none}.marker\:content-none ::-webkit-details-marker{--tw-content:none;content:none}.marker\:content-none::-webkit-details-marker{--tw-content:none;content:none}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-current:hover{border-color:currentColor}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}}.hover\:bg-black\/40:hover{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/40:hover{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.hover\:bg-black\/65:hover{background-color:#000000a6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/65:hover{background-color:color-mix(in oklab,var(--color-black) 65%,transparent)}}.hover\:bg-black\/75:hover{background-color:#000000bf}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/75:hover{background-color:color-mix(in oklab,var(--color-black) 75%,transparent)}}.hover\:bg-black\/\[0\.02\]:hover{background-color:#00000005}@supports (color:color-mix(in lab,red,red)){.hover\:bg-black\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-black) 2%,transparent)}}.hover\:bg-current\/10:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/10:hover{background-color:color-mix(in oklab,currentcolor 10%,transparent)}}.hover\:bg-current\/25:hover{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){.hover\:bg-current\/25:hover{background-color:color-mix(in oklab,currentcolor 25%,transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab,var(--color-white) 25%,transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab,var(--color-white) 90%,transparent)}}.hover\:text-current:hover{color:currentColor}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-45:hover{opacity:.45}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-85:hover{opacity:.85}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-0:disabled{opacity:0}.disabled\:opacity-25:disabled{opacity:.25}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}@media(prefers-reduced-motion:no-preference){.motion-safe\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}@media(min-width:40rem){.sm\:top-5{top:calc(var(--spacing) * 5)}.sm\:right-5{right:calc(var(--spacing) * 5)}.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:left-6{left:calc(var(--spacing) * 6)}.sm\:mb-6{margin-bottom:calc(var(--spacing) * 6)}.sm\:mb-8{margin-bottom:calc(var(--spacing) * 8)}.sm\:mb-10{margin-bottom:calc(var(--spacing) * 10)}.sm\:mb-14{margin-bottom:calc(var(--spacing) * 14)}.sm\:-ml-4{margin-left:calc(var(--spacing) * -4)}.sm\:ml-8{margin-left:calc(var(--spacing) * 8)}.sm\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:table-cell{display:table-cell}.sm\:aspect-4\/3{aspect-ratio:4/3}.sm\:aspect-16\/9{aspect-ratio:16/9}.sm\:aspect-video{aspect-ratio:var(--aspect-video)}.sm\:w-28{width:calc(var(--spacing) * 28)}.sm\:w-32{width:calc(var(--spacing) * 32)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[18rem\]{max-width:18rem}.sm\:max-w-\[60\%\]{max-width:60%}.sm\:flex-\[0_0_72\%\]{flex:0 0 72%}.sm\:shrink-0{flex-shrink:0}.sm\:basis-1\/2{flex-basis:50%}.sm\:basis-1\/3{flex-basis:33.3333%}.sm\:basis-\[56\%\]{flex-basis:56%}.sm\:columns-2{columns:2}.sm\:columns-3{columns:3}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}.sm\:gap-8{gap:calc(var(--spacing) * 8)}.sm\:gap-10{gap:calc(var(--spacing) * 10)}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-24>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 24) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 24) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.sm\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.sm\:self-auto{align-self:auto}.sm\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.sm\:p-3\.5{padding:calc(var(--spacing) * 3.5)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:px-10{padding-inline:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:py-24{padding-block:calc(var(--spacing) * 24)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-6{padding-bottom:calc(var(--spacing) * 6)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:first\:pl-0:first-child{padding-left:0}}@media(min-width:48rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-6{grid-column:span 6/span 6}.md\:col-span-7{grid-column:span 7/span 7}.md\:col-span-12{grid-column:span 12/span 12}.md\:flex{display:flex}.md\:hidden{display:none}.md\:table-cell{display:table-cell}.md\:aspect-5\/4{aspect-ratio:5/4}.md\:h-\[78vh\]{height:78vh}.md\:min-h-\[580px\]{min-height:580px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:grid-cols-\[240px_minmax\(0\,1fr\)\]{grid-template-columns:240px minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:gap-12{gap:calc(var(--spacing) * 12)}:where(.md\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.md\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.md\:justify-self-end{justify-self:flex-end}.md\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.md\:pb-16{padding-bottom:calc(var(--spacing) * 16)}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-0{top:0}.lg\:top-12{top:calc(var(--spacing) * 12)}.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:order-none{order:0}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:col-span-12{grid-column:span 12/span 12}.lg\:mt-6{margin-top:calc(var(--spacing) * 6)}.lg\:mt-24{margin-top:calc(var(--spacing) * 24)}.lg\:mt-auto{margin-top:auto}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:aspect-3\/2{aspect-ratio:3/2}.lg\:aspect-auto{aspect-ratio:auto}.lg\:h-fit{height:fit-content}.lg\:h-full{height:100%}.lg\:h-screen{height:100vh}.lg\:min-h-\[24rem\]{min-height:24rem}.lg\:min-h-\[34rem\]{min-height:34rem}.lg\:w-\[18\.75rem\]{width:18.75rem}.lg\:w-\[260px\]{width:260px}.lg\:w-auto{width:auto}.lg\:shrink-0{flex-shrink:0}.lg\:basis-1\/3{flex-basis:33.3333%}.lg\:basis-1\/4{flex-basis:25%}.lg\:basis-auto{flex-basis:auto}.lg\:snap-none{scroll-snap-type:none}.lg\:columns-4{columns:4}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:gap-0{gap:0}.lg\:gap-3{gap:calc(var(--spacing) * 3)}.lg\:gap-4{gap:calc(var(--spacing) * 4)}.lg\:gap-5{gap:calc(var(--spacing) * 5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-7{gap:calc(var(--spacing) * 7)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-10{gap:calc(var(--spacing) * 10)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}.lg\:gap-14{gap:calc(var(--spacing) * 14)}.lg\:gap-20{gap:calc(var(--spacing) * 20)}:where(.lg\:space-y-28>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 28) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 28) * calc(1 - var(--tw-space-y-reverse)))}.lg\:gap-x-12{column-gap:calc(var(--spacing) * 12)}.lg\:overflow-visible{overflow:visible}.lg\:overflow-y-auto{overflow-y:auto}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}.lg\:px-14{padding-inline:calc(var(--spacing) * 14)}.lg\:py-10{padding-block:calc(var(--spacing) * 10)}.lg\:py-16{padding-block:calc(var(--spacing) * 16)}.lg\:pt-14{padding-top:calc(var(--spacing) * 14)}.lg\:pt-16{padding-top:calc(var(--spacing) * 16)}.lg\:pb-0{padding-bottom:0}.lg\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.lg\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.lg\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.lg\:text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}}@media(min-width:80rem){.xl\:col-span-4{grid-column:span 4/span 4}.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-7{grid-column:span 7/span 7}.xl\:col-span-8{grid-column:span 8/span 8}.xl\:col-start-1{grid-column-start:1}.xl\:col-start-5{grid-column-start:5}.xl\:row-start-1{grid-row-start:1}.xl\:-mt-24{margin-top:calc(var(--spacing) * -24)}.xl\:mr-16{margin-right:calc(var(--spacing) * 16)}.xl\:mb-12{margin-bottom:calc(var(--spacing) * 12)}.xl\:-ml-10{margin-left:calc(var(--spacing) * -10)}.xl\:ml-auto{margin-left:auto}.xl\:block{display:block}.xl\:w-\[50\%\]{width:50%}.xl\:w-\[64\%\]{width:64%}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:gap-x-10{column-gap:calc(var(--spacing) * 10)}.xl\:self-end{align-self:flex-end}.xl\:px-8{padding-inline:calc(var(--spacing) * 8)}.xl\:py-8{padding-block:calc(var(--spacing) * 8)}.xl\:py-12{padding-block:calc(var(--spacing) * 12)}.xl\:py-16{padding-block:calc(var(--spacing) * 16)}.xl\:pt-12{padding-top:calc(var(--spacing) * 12)}.xl\:pr-8{padding-right:calc(var(--spacing) * 8)}.xl\:pr-16{padding-right:calc(var(--spacing) * 16)}.xl\:pb-56{padding-bottom:calc(var(--spacing) * 56)}.xl\:pl-10{padding-left:calc(var(--spacing) * 10)}.xl\:pl-20{padding-left:calc(var(--spacing) * 20)}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\>li\]\:mb-2>li{margin-bottom:calc(var(--spacing) * 2)}.\[\&\>li\]\:mb-3>li{margin-bottom:calc(var(--spacing) * 3)}@media(min-width:40rem){.sm\:\[\&\>li\]\:mb-4>li{margin-bottom:calc(var(--spacing) * 4)}}}@font-face{font-family:Pretendard Variable;font-weight:45 920;font-style:normal;font-display:swap;src:url(/fonts/PretendardVariable.woff2)format("woff2")}html{-webkit-text-size-adjust:100%}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;word-break:keep-all;overflow-wrap:break-word;margin:0;padding:0}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}html{scrollbar-width:thin}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@media(prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}:where(.site,.site-canvas){--fs-display:clamp(1.75rem, 1.25rem + 2.2vw, 3rem);--fs-h2:clamp(1.3125rem, 1.15rem + .7vw, 1.75rem);--fs-h3:clamp(1.0625rem, 1rem + .3vw, 1.25rem);--fs-lead:clamp(.9375rem, .9rem + .22vw, 1.0625rem);--fs-body:clamp(.9375rem, .91rem + .15vw, 1rem);--fs-sm:clamp(.8125rem, .8rem + .1vw, .875rem);--fs-xs:clamp(.75rem, .74rem + .06vw, .78125rem);--section-space:clamp(2.25rem, 5vw, var(--tpl-section-space,3.5rem));--site-line:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line:color-mix(in oklab, currentColor 14%, transparent)}}:where(.site,.site-canvas){--site-line-soft:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-line-soft:color-mix(in oklab, currentColor 8%, transparent)}}:where(.site,.site-canvas){--site-muted:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas){--site-muted:color-mix(in oklab, currentColor 62%, transparent)}}:where(.site,.site-canvas) .serif{font-family:var(--tpl-font-heading,var(--font-serif));letter-spacing:var(--tpl-heading-tracking,normal)}:where(.site,.site-canvas) :is(.serif,.h2,.h3){font-synthesis-weight:none}:where(.site,.site-canvas) .tpl-border{border-width:var(--tpl-border-width,1px)}:where(.site,.site-canvas) .tpl-shadow{box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .shell{width:100%;max-width:78rem;margin-inline:auto;padding-inline:clamp(1rem,4vw,2.5rem)}:where(.site,.site-canvas) .line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:where(.site,.site-canvas) .measure{max-width:68ch}:where(.site,.site-canvas) .paper{background-image:var(--tpl-texture,none)}:where(.site,.site-canvas) .panel{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel{background-color:color-mix(in oklab,currentColor 5%,transparent)}}:where(.site,.site-canvas) .panel{border:var(--tpl-border-width,1px) solid var(--site-line);border-radius:var(--tpl-radius,.75rem);box-shadow:var(--tpl-shadow,none)}:where(.site,.site-canvas) .panel-sunken{background-color:currentColor}@supports (color:color-mix(in lab,red,red)){:where(.site,.site-canvas) .panel-sunken{background-color:color-mix(in oklab,currentColor 6%,transparent)}}:where(.site,.site-canvas) .h2{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h2);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.01em);line-height:1.2}:where(.site,.site-canvas) .h3{font-family:var(--tpl-font-heading,var(--font-serif));font-size:var(--fs-h3);font-weight:var(--tpl-heading-weight,700);letter-spacing:var(--tpl-heading-tracking,-.005em);line-height:1.35}:where(.site,.site-canvas) .label{font-size:var(--fs-xs);color:var(--site-muted);font-weight:600}:where(.site,.site-canvas) .tap{min-width:2.75rem;min-height:2.75rem}:where(.site,.site-canvas) .only-touch{display:none}@media(hover:none)and (pointer:coarse){:where(.site,.site-canvas) .only-touch{display:flex}}:where(.site,.site-canvas) .safe-b{padding-bottom:calc(.5rem + env(safe-area-inset-bottom))}:where(.site,.site-canvas) .slider-viewport{scrollbar-width:none;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;overflow-x:auto}:where(.site,.site-canvas) .slider-viewport::-webkit-scrollbar{display:none}:where(.site,.site-canvas) .slider-viewport[data-slider=on]{scroll-snap-type:none;overflow-x:hidden}:where(.site,.site-canvas) .slider-track{display:flex}:where(.site,.site-canvas) .slider-track>*{scroll-snap-align:start}:where(.site,.site-canvas) .slider-viewport[data-dragging=true]{cursor:grabbing;-webkit-user-select:none;user-select:none}:where(.site,.site-canvas) .text-muted{color:var(--site-muted)}:where(.site,.site-canvas) .border-line,:where(.site,.site-canvas) .divide-line>:not(:last-child){border-color:var(--site-line)}:root{--tpl-primary:#18181b;--tpl-secondary:#52525b;--tpl-bg:#fff;--tpl-card:#fafafa;--tpl-text:#09090b;--tpl-accent:#2563eb;--tpl-surface:#fff;--tpl-surface-alt:#f5f5f4;--tpl-inverse:#1c1917;--tpl-border:#e7e5e4;--tpl-font-heading:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-font-body:"Pretendard Variable", "Noto Sans KR", system-ui, sans-serif;--tpl-radius:.75rem;--tpl-border-width:1px;--tpl-shadow:0 1px 2px #0000000f;--tpl-heading-tracking:-.02em;--tpl-heading-weight:700;--tpl-section-space:4rem}html{scroll-behavior:smooth;scroll-padding-top:5.5rem}body{background-color:var(--color-surface);background-image:var(--tpl-texture,none);color:var(--color-ink);font-family:var(--tpl-font-body,var(--font-sans));font-size:var(--fs-body);font-feature-settings:"tnum" 1;padding-bottom:env(safe-area-inset-bottom);line-height:1.7}:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0} diff --git a/solution/site/scripts/mockup/vendor/index-DUeGvBPS.js b/solution/site/scripts/mockup/vendor/retired/index-DUeGvBPS.js similarity index 100% rename from solution/site/scripts/mockup/vendor/index-DUeGvBPS.js rename to solution/site/scripts/mockup/vendor/retired/index-DUeGvBPS.js diff --git a/solution/site/scripts/mockup/vendor/index-k3f3eOzb.css b/solution/site/scripts/mockup/vendor/retired/index-k3f3eOzb.css similarity index 100% rename from solution/site/scripts/mockup/vendor/index-k3f3eOzb.css rename to solution/site/scripts/mockup/vendor/retired/index-k3f3eOzb.css diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index d40233b..3d3e33f 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -1,17 +1,21 @@ import { copyFileSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, + symlinkSync, 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 type {SitePayload} from '@o2o/shared'; +import {SourceType, type SitePayload} from '@o2o/shared'; import { joinUrl, parseSectionData, @@ -24,6 +28,7 @@ import { homeMeta, readBakedLastmod, readBakedNoindex, + readBakedOrigin, readBakedTitle, renderHead, renderLlmsTxt, @@ -72,9 +77,60 @@ import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; * API 가 열리면 --payload=https://... 를 지원하는 것으로 충분하다. */ -/** 사이트별 산출물이 놓이는 디렉터리. 발행 주소 `<host>/s/<slug>` 와 같은 모양이다. */ +/** 공개 주소가 읽는 디렉터리. 발행 주소 `<host>/s/<slug>` 와 같은 모양이다. */ const SITE_DIR = 's'; +/** + * 실제로 굽는 자리. `out/s/<slug>` 는 여기를 가리키는 **심볼릭 링크**일 뿐이다. + * + * ★ 왜 (발행 버전 시스템, 2026-09-15) + * 예전에는 `out/s/<slug>/` 를 곧바로 덮어 썼다 — 발행마다 직전 HTML 이 사라지므로 롤백은 + * DB 스냅샷에서 **처음부터 다시 굽는 것**뿐이었고, 그마저도 "지금 공개된 버전"이 뭔지 + * 디렉토리 자체로는 알 길이 없었다(사고 중에는 site_versions 를 읽어야 했다). + * 지금은 버전마다 **자기만의 디렉토리**를 갖는다 — `out/versions/<slug>/<version>/`. + * 발행은 굽기가 **끝난 뒤** `out/s/<slug>` 링크를 그 버전으로 원자적으로 돌리는 것 하나다 + * (publishVersion). 실패하면 링크를 안 돌리므로 **직전 공개본이 그대로 서비스된다.** + * ★ 롤백은 재굽기가 필요 없다 — 대상 버전 디렉토리가 보관 기간 안에 남아 있으면 링크만 + * 되돌린다. 지워졌으면(pruneOldVersions) 백엔드가 site_versions.snapshot 으로 그 버전을 + * 다시 굽고 나서 링크를 돌린다(rollback_service.py) — 같은 publishVersion 경로를 탄다. + */ +const VERSIONS_DIR = 'versions'; + +/** + * 이 코드가 처음 도는 순간 `out/s/<slug>` 가 이미 **일반 디렉토리**(옛 방식의 산출물)일 수 + * 있다. 지우고 새로 구우면 "기존 사이트 전체 재굽기 금지" 를 깬다 — 그 자리를 이 이름으로 + * `out/versions/<slug>/legacy/` 에 옮겨 붙인다(마이그레이션). 보관 대상에서 빼지 않는다 — + * DB 버전 번호와 대응이 없어 롤백 목록에는 안 뜨지만, 자산 참조 스캔·수동 복구용으로 남긴다. + */ +const LEGACY_VERSION = 'legacy'; + +/** + * 손으로 만든 시연본(목업)이 차지한 슬러그. **이 슬러그는 굽지 않는다.** + * + * ★ 왜 (2026-09-15, 실측) + * `/s/stay` 는 payload 가 없는 목업이고 그 index.html 이 **유일본**이다(AGENTS.md 함정 1). + * 그런데 누군가 빌더에서 슬러그 `stay` 로 발행하자 `payloads/stay.json` 이 생겼고, 프리렌더가 + * 기동하며 그 payload 로 같은 자리를 구워 **목업을 통째로 덮었다** — 캐치프레이즈 100개 · + * 미니 플레이어 · 날씨 문구 · 주입분이 전부 사라졌다. 파일을 백업에서 되돌려야 했다. + * "payload 가 없으면 안 굽는다" 는 목업을 지켜 주지 못한다. **payload 가 생기는 순간** 덮인다. + * + * ★ 슬러그를 막는 것이지 발행을 막는 것이 아니다. 백엔드는 발행에 성공했다고 보고, 여기서 + * 실패 보고서를 써 준다 — 사장님 화면에 사유가 뜨고, 목업은 그대로 남는다. + * + * ★ `stay` 하나만 막는다. `stay2` · `stay3` 도 원래 목업이었지만 그 슬러그는 이미 실제 사이트가 + * 가져가 발행 중이라(버터브루 = stay2) 막으면 그 사이트가 재굽기에서 빠진다. + * 시연본은 `/s/stay` 다(mockup/README 2.1). + * + * ★ 목업을 늘리거나 걷어낼 때는 `PRERENDER_PROTECTED_SLUGS`(쉼표) 로 덮어쓴다. 값을 비우면 + * 보호가 전부 풀린다 — 목업을 정식 사이트로 넘길 때만 그렇게 한다. + */ +const PROTECTED_SLUGS = new Set( + (process.env.PRERENDER_PROTECTED_SLUGS ?? 'stay') + .split(',') + .map((slug) => slug.trim()) + .filter(Boolean), +); + const HERE = dirname(fileURLToPath(import.meta.url)); const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/ // 백엔드가 노래 파일을 떨구는 자리. payload 디렉토리와 나란히 둔다(한 디렉토리 약속). @@ -85,14 +141,26 @@ interface Args { /** --payload 은 여러 번 줄 수 있다. 비면 데모 payload 로 굽는다. */ payloads: string[]; out: string; + /** 아무것도 굽지 않고 공용 자산(out/assets · out/fonts · public/)만 채우고 정리한다. */ + seedAssets: boolean; + stageOnly: boolean; + activate?: string; } function parseArgs(): Args { - const args: Args = {payloads: [], out: join(SITE_ROOT, 'out')}; + const args: Args = { + payloads: [], + out: join(SITE_ROOT, 'out'), + seedAssets: false, + stageOnly: false, + }; 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 === 'seed-assets') args.seedAssets = true; + if (key === 'stage-only') args.stageOnly = true; + if (key === 'activate') args.activate = value; } return args; } @@ -147,6 +215,9 @@ function loadOne(file: string): LoadedPayload { if (!payload.site?.slug) { return {file, payload, error: 'payload 에 site.slug 이 없습니다'}; } + if (!/^[\p{L}\p{N}_-]+$/u.test(payload.site.slug) || !Number.isSafeInteger(payload.site.version) || payload.site.version < 1) { + return {file, payload, error: '잘못된 슬러그 또는 발행 버전입니다'}; + } return {file, payload}; } catch (ex) { return {file, error: ex instanceof Error ? ex.message : String(ex)}; @@ -271,6 +342,194 @@ function copyDirectoryFiles(source: string, destination: string) { } } +/** 내려받은 사진이 놓이는 폴더. 사이트 디렉토리 안이라 Azure 발행 때 함께 올라간다. */ +const MEDIA_DIR = 'img'; + +/** 한 장당 상한(바이트). 원본이 통짜 PNG 인 경우가 있어 막아 둔다 — 넘으면 원래 주소로 둔다. */ +const MEDIA_MAX_BYTES = 8 * 1024 * 1024; + +const MEDIA_EXT: Record<string, string> = { + '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, siteDir: string) { + const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? '')); + if (items.length === 0) return; + + const dir = join(siteDir, MEDIA_DIR); + // ★ 절대 주소로 바꾼다. 이 주소는 `<img src>` 뿐 아니라 og:image · JSON-LD 의 image 로도 + // 나가는데, 그 둘은 절대 주소여야 한다(상대 주소를 주면 크롤러마다 다르게 읽는다). + // 수집 주소도 절대였으니 바뀌는 것은 호스트뿐이다. + const publicBase = joinUrl(payload.site.origin, payload.site.basePath); + mkdirSync(dir, {recursive: true}); + + const wanted = new Set<string>(); + 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}장)`); +} + +/** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions/<slug>/<version>/`. */ +function versionDir(outRoot: string, slug: string, version: number | string): string { + return join(outRoot, VERSIONS_DIR, slug, String(version)); +} + +/** `out/s/<slug>` 가 (이 코드 이전에 구워진) 일반 디렉토리인가 — 심볼릭 링크가 아니면 옛 산출물이다. */ +function isLegacyFlatSite(publicDir: string): boolean { + try { + return lstatSync(publicDir).isDirectory(); + } catch { + return false; // 없음(첫 발행) — 마이그레이션 대상 아님. + } +} + +/** + * 공개 주소(`out/s/<slug>`)를 이 버전으로 **원자적으로** 돌린다. + * + * ★ 왜 심볼릭 링크인가 — 임시 이름으로 링크를 만들고 `renameSync` 로 덮어씌운다. POSIX 에서 + * 같은 디렉토리 안의 rename 은 원자적이다(파일시스템 저널이 "옛 링크"와 "새 링크" 사이의 + * 중간 상태를 방문자에게 보여주지 않는다). 그래서 굽는 동안 크래시가 나거나 게이트가 + * 막아도 **공개 주소는 절대 절반만 바뀐 상태가 되지 않는다** — 직전 버전이거나 이번 + * 버전이거나 둘 중 하나다. + * ★ 첫 발행이 아니고 `out/s/<slug>` 가 아직 **일반 디렉토리**(이 코드 이전 산출물)면, 지우지 + * 않고 `out/versions/<slug>/legacy/` 로 옮겨 붙인다 — 그 자리가 유일한 사본인 사이트가 + * 있을 수 있어서다(옛 굽기는 이력을 안 남겼다). 옮긴 뒤에는 일반 심볼릭 링크 전환과 같다. + */ +function publishVersion(outRoot: string, slug: string, version: number): void { + const publicDir = join(outRoot, SITE_DIR, slug); + const target = versionDir(outRoot, slug, version); + if (!existsSync(target)) { + throw new Error(`publishVersion: 대상 버전 디렉토리가 없다 — ${target}`); + } + + if (isLegacyFlatSite(publicDir)) { + const legacy = versionDir(outRoot, slug, LEGACY_VERSION); + if (!existsSync(legacy)) { + mkdirSync(dirname(legacy), {recursive: true}); + renameSync(publicDir, legacy); + console.log(` (마이그레이션) 옛 산출물을 versions/${slug}/${LEGACY_VERSION}/ 로 보존`); + } else { + // legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 + // 둘을 합치지 않는다. 그대로 둔 채 아래에서 심볼릭 링크로 덮어쓴다(원자적 교체). + throw new Error('기존 사이트와 legacy가 함께 있어 자동 전환을 중단합니다'); + } + } + + const tmp = join(outRoot, SITE_DIR, `.${slug}.tmplink`); + mkdirSync(dirname(tmp), {recursive: true}); // 첫 발행이면 `out/s/` 자체가 아직 없다. + rmSync(tmp, {force: true}); + // 상대 경로로 링크한다 — named volume 을 다른 마운트 지점(로컬·컨테이너)에서 봐도 깨지지 않는다. + symlinkSync(join('..', VERSIONS_DIR, slug, String(version)), tmp, 'dir'); + renameSync(tmp, publicDir); +} + +/** + * 오래된 버전 디렉토리를 지운다. **지금 공개된 버전과 `legacy` 는 절대 지우지 않는다.** + * + * ★ 보관 정책 — `out/assets` 의 자산 보관(ASSET_RETENTION_DAYS)과 같은 사고방식이다: + * 최근 N개(ROLLBACK_MIN_VERSIONS) 또는 최근 D일(ROLLBACK_RETENTION_DAYS) 중 **더 오래 + * 남기는 쪽**을 따른다. 둘 다 지난 버전만 지운다 — 롤백 대상은 이 안에서 고른다. + * ★ 지워진 버전도 DB(site_versions.snapshot) 에는 남는다. 그 버전으로 롤백하면 + * `rollback_service.py` 가 snapshot 으로 **다시 굽는다** — 디스크에서 지운 것이지 + * 되돌릴 수 없게 잃은 것이 아니다(자산과 달리 재생성 비용이 있을 뿐이다). + */ +const ROLLBACK_MIN_VERSIONS = 5; +const ROLLBACK_RETENTION_DAYS = 30; + +function pruneOldVersions(outRoot: string, slug: string, currentVersion: number): void { + const base = join(outRoot, VERSIONS_DIR, slug); + if (!existsSync(base)) return; + + const numbered = readdirSync(base, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() && entry.name !== LEGACY_VERSION) + .map((entry) => Number(entry.name)) + .filter((n) => Number.isFinite(n)) + .sort((a, b) => b - a); + + const cutoff = Date.now() - ROLLBACK_RETENTION_DAYS * 24 * 60 * 60 * 1000; + let removed = 0; + numbered.forEach((version, index) => { + if (version === currentVersion) return; // 지금 공개된 버전은 몇 번째든 남긴다. + if (index < ROLLBACK_MIN_VERSIONS) return; + const dir = join(base, String(version)); + let mtime = 0; + try { + mtime = statSync(dir).mtimeMs; + } catch { + return; + } + if (mtime >= cutoff) return; + rmSync(dir, {recursive: true, force: true}); + removed += 1; + }); + if (removed > 0) { + console.log(` 옛 버전 디렉토리 ${removed}개 정리 (최근 ${ROLLBACK_MIN_VERSIONS}개·${ROLLBACK_RETENTION_DAYS}일은 항상 남긴다)`); + } +} + function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { const basePath = payload.site.basePath.replace(/\/+$/, ''); const suffix = `/${SITE_DIR}/${payload.site.slug}`; @@ -345,10 +604,9 @@ function prerenderSite( // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) const payload = sanitizePayloadForPublish(input); - // ★ 발행 주소가 `<host>/s/<slug>` 이므로 파일도 같은 모양으로 놓는다. - // 그래야 out/ 을 그대로 정적 서빙하면 발행된 모든 사이트가 실제 주소로 열린다 — - // 사이트마다 서버를 따로 띄우거나 경로를 손으로 맞출 필요가 없다. - const siteDir = join(outRoot, SITE_DIR, payload.site.slug); + // ★ 이번 버전 전용 디렉토리에 굽는다(`out/s/<slug>` 가 아니다) — publishVersion 주석 참조. + // 실패해도 이 디렉토리만 지저분해질 뿐 공개 주소는 안 건드린다. + const siteDir = versionDir(outRoot, payload.site.slug, payload.site.version); const plan = assetPlan(payload, outRoot, siteDir); /** 브라우저가 자산을 찾아갈 접두사. 파일이 놓인 자리와 같아야 한다. */ const assetBase = plan.base; @@ -495,6 +753,8 @@ function countUniqueContent(payload: SitePayload): number { if (fact.type === 'text' && long(fact.value)) count += 1; } for (const faq of payload.faqs) { + // 문의 안내(TEMPLATE)는 모든 가게에 같은 문구다 — 세면 내용 없는 사이트가 게이트를 통과한다. + if (faq.sourceType === SourceType.TEMPLATE) continue; if (String(faq.question ?? '').trim() && long(faq.answer)) count += 1; } for (const item of payload.media) { @@ -598,20 +858,51 @@ function listRelativeFiles(dir: string, prefix = ''): string[] { * 똑같은 일이 난다. **참조가 살아 있는 한 남긴다** 가 유일하게 맞는 규칙이다. * * ★ 사이트를 굽기 전에 부른다. 그래야 이번에 다시 굽지 않는 사이트의 옛 참조가 잡힌다. + * ★ 공개 자리(`out/s/<slug>`)뿐 아니라 `out/versions/<slug>/<옛 버전>/` 도 훑는다 — 롤백 + * 대상으로 보관 중인 버전(pruneOldVersions 가 아직 안 지운 것)이 가리키는 번들도 살려야 + * 링크만 돌려 롤백할 때 CSS·JS 가 404 가 안 난다. */ -function referencedAssets(outRoot: string): Set<string> { - const sitesDir = join(outRoot, SITE_DIR); - const found = new Set<string>(); - if (!existsSync(sitesDir)) return found; +function isDirLike(parent: string, entry: {name: string; isDirectory(): boolean; isSymbolicLink(): boolean}): boolean { + // `out/s/<slug>` 는 심볼릭 링크다(publishVersion). readdirSync 의 Dirent 는 링크 자체의 + // 타입만 보고하므로, 대상이 디렉토리인지는 statSync 로 한 번 더 확인해야 한다. + if (entry.isDirectory()) return true; + if (!entry.isSymbolicLink()) return false; + try { + return statSync(join(parent, entry.name)).isDirectory(); + } catch { + return false; // 끊어진 링크 — 대상이 지워졌다. + } +} - for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { - if (!entry.isDirectory()) continue; - const file = join(sitesDir, entry.name, 'index.html'); - if (!existsSync(file)) continue; +function referencedAssets(outRoot: string): Set<string> { + const found = new Set<string>(); + + const scanIndexHtml = (file: string) => { + if (!existsSync(file)) return; // basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다. for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) { found.add(decodeURIComponent(match[1])); } + }; + + const sitesDir = join(outRoot, SITE_DIR); + if (existsSync(sitesDir)) { + for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { + if (!isDirLike(sitesDir, entry)) continue; + scanIndexHtml(join(sitesDir, entry.name, 'index.html')); + } + } + + const versionsDir = join(outRoot, VERSIONS_DIR); + if (existsSync(versionsDir)) { + for (const slugEntry of readdirSync(versionsDir, {withFileTypes: true})) { + if (!slugEntry.isDirectory()) continue; + const slugDir = join(versionsDir, slugEntry.name); + for (const versionEntry of readdirSync(slugDir, {withFileTypes: true})) { + if (!versionEntry.isDirectory()) continue; + scanIndexHtml(join(slugDir, versionEntry.name, 'index.html')); + } + } } return found; } @@ -759,12 +1050,34 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string console.log(' ✓ 빌더 미리보기 셸 (/preview)'); } +/** + * 이미 구워져 있는 사이트에서 오리진을 알아낸다. 한 장도 없으면 빈 문자열. + * + * ★ payload 를 읽지 않는 경로(`--refresh-assets` 기동)에서 루트 색인 파일을 다시 쓰려면 + * 오리진이 필요한데, 그 값은 payload 에만 있다. 디스크에 있는 페이지가 자기 canonical 로 + * 선언해 둔 것을 쓴다(readBakedOrigin 주석). + */ +function findBakedOrigin(outRoot: string): string { + const sitesDir = join(outRoot, SITE_DIR); + if (!existsSync(sitesDir)) return ''; + for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { + if (!entry.isDirectory()) continue; + const file = join(sitesDir, entry.name, 'index.html'); + if (!existsSync(file)) continue; + const origin = readBakedOrigin(readFileSync(file, 'utf-8')); + if (origin) return origin; + } + return ''; +} + function writeRootMachineFiles(outRoot: string, origin: string) { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return; const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true}) - .filter((entry) => entry.isDirectory()) + // `out/s/<slug>` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 + // 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다. + .filter((entry) => isDirLike(sitesDir, entry)) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) @@ -827,10 +1140,45 @@ function writeIndexNowKey(outRoot: string) { console.log(' ✓ IndexNow 키 파일'); } -function main() { +async function main() { const args = parseArgs(); - const loaded = loadPayloads(args.payloads); const assets = readAssets(); + if (args.activate) { + const match = /^([\p{L}\p{N}_-]+):([1-9][0-9]*)$/u.exec(args.activate); + if (!match || PROTECTED_SLUGS.has(match[1])) throw new Error('잘못되거나 보호된 발행 대상'); + const [, slug, version] = match; + const saved = JSON.parse(readFileSync(join(versionDir(args.out, slug, version), '.render-report.json'), 'utf8')); + if (!saved.ok || saved.slug !== slug || saved.siteVersion !== Number(version)) throw new Error('검증된 버전이 아닙니다'); + publishVersion(args.out, slug, Number(version)); + writeRootMachineFiles(args.out, saved.origin); + return; + } + + /* + * ★ 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 + * 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. HTML 을 하나도 + * 건드리지 않으므로 예전 refreshBakedAssets 와 달리 공개된 사이트의 내용·주소 참조에 + * 아무 영향이 없다 — 운영 워커(render_service.py)는 이 모드를 쓰지 않는다(BUILD 잡마다 + * 실제 payload 로 호출하고, 그 경로가 매번 writeSharedAssets 를 이미 돈다). + */ + if (args.seedAssets) { + console.log(`[prerender] 공용 자산만 시딩 → ${args.out}`); + writeSharedAssets(args.out, referencedAssets(args.out)); + writePreviewShell(args.out, assets); + /* + * ★ 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. + * 이 파일들은 **디스크의 `out/s/` 를 훑어** 만들어지므로 내용은 손대지 않는다 — + * 페이지 HTML 은 그대로고, 사라진 사이트가 목록에서 빠지고 남아 있는 사이트는 그대로다. + * 이게 없던 동안 내려간 사이트가 사이트맵에 계속 남았다: 페이지는 404 인데 구글은 + * 그 주소를 계속 크롤하고, `/s` 목록에는 열리지 않는 카드가 남았다(실측 2026-09-15). + */ + const bakedOrigin = findBakedOrigin(args.out); + if (bakedOrigin) writeRootMachineFiles(args.out, bakedOrigin); + console.log('[prerender] 완료'); + return; + } + + const loaded = loadPayloads(args.payloads); console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); @@ -882,19 +1230,52 @@ function main() { continue; } + // ★ 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS). + if (PROTECTED_SLUGS.has(entry.payload.site.slug)) { + fail( + entry, + `슬러그 '${entry.payload.site.slug}' 는 시연본이 쓰고 있어 발행할 수 없습니다. 다른 슬러그로 발행해 주세요.`, + ); + continue; + } + try { + // ★ 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 + // payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. + // 이번 버전 전용 디렉토리에 내려받는다 — 공개 주소는 아직 안 건드린다. + const stagingDir = versionDir(args.out, entry.payload.site.slug, entry.payload.site.version); + const savedReportPath = join(stagingDir, '.render-report.json'); + // 성공한 버전은 불변이다. 재시도·롤백이 옛 HTML과 번들을 다시 쓰지 않는다. + if (existsSync(savedReportPath) && existsSync(join(stagingDir, 'index.html'))) { + const saved = JSON.parse(readFileSync(savedReportPath, 'utf8')); + if (!saved.ok || saved.siteId !== entry.payload.site.siteId) throw new Error('저장 버전 불일치'); + if (entry.file) writeReport(entry.file, saved); + if (entry.payload.site.publish && !args.stageOnly) { + publishVersion(args.out, entry.payload.site.slug, entry.payload.site.version); + origin = saved.origin; + } + continue; + } + await mirrorMedia(entry.payload, stagingDir); const result = prerenderSite(entry.payload, args.out, assets, referenced); const payload = result.payload; origin = origin || payload.site.origin; - const home = joinUrl(payload.site.origin, payload.site.basePath); - console.log( - ` ✓ ${payload.place.name} (${payload.site.slug}) — index.html + llms.txt`, - ); + + // ★ publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 + // `out/s/<slug>` 는 손대지 않는다. BUILD 잡이 publish=false 로 넘긴 경우가 이 길이다. + if (payload.site.publish && !args.stageOnly) { + publishVersion(args.out, payload.site.slug, payload.site.version); + pruneOldVersions(args.out, payload.site.slug, payload.site.version); + const home = joinUrl(payload.site.origin, payload.site.basePath); + console.log(` ✓ ${payload.place.name} (${payload.site.slug}) — 발행 v${payload.site.version}`); + console.log(` ${home}`); + } else { + console.log(` ✓ ${payload.place.name} (${payload.site.slug}) — 빌드만(v${payload.site.version}, 공개 주소는 그대로)`); + } console.log(` ${result.siteDir}`); - console.log(` ${home}`); if (entry.file) { - writeReport(entry.file, { + const report: RenderReport = { schemaVersion: 1, slug: payload.site.slug, siteId: payload.site.siteId, @@ -909,7 +1290,9 @@ function main() { jsonld: result.jsonld, mismatches: [], error: null, - }); + }; + writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin})); + writeReport(entry.file, report); } } catch (ex) { // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — @@ -939,4 +1322,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..7b86c0d 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,19 @@ function prerender(files, reason) { return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label); } +/** + * 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. **굽지 않는다.** + * + * ★ 발행 버전 시스템(2026-09-15)으로 바뀌면서 "이미 구워진 HTML 의 자산 주소만 갈아 끼우는" + * 예전 방식(refreshBakedAssets)은 없어졌다 — 버전마다 자기 디렉토리에 굽고 공개 심볼릭 + * 링크(`out/s/<slug>`)는 발행할 때만 돈다(prerender.ts publishVersion). 그래서 기동 시 + * 할 일은 아직 하나도 안 구워진 payload 를 굽는 것과, 그 전에 공용 자산을 한 번 깔아 + * 두는 것뿐이다 — HTML 은 하나도 건드리지 않는다. + */ +function seedAssets() { + return run('node', [PRERENDER_JS, '--seed-assets'], '공용 자산 시딩 — 기동'); +} + // ── 큐 ──────────────────────────────────────────────────────────────────── /** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */ const pending = new Set(); @@ -209,6 +223,11 @@ function basenameOf(file) { return file.slice(file.lastIndexOf('/') + 1); } +/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `<slug>.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 +247,16 @@ async function main() { return; } - // 기동 시 한 번은 전부 굽는다. 감시가 꺼져 있는 동안 발행된 것도 사이트가 있어야 하고, - // 코드가 바뀌었다면 번들이 새로 나왔으므로 기존 사이트도 다시 구워야 맞다. + // ★ 기동 시 전부 굽지 않는다(seedAssets 주석). 공용 자산만 깔아 두고, 내용은 + // 사장님이 다시 발행할 때(또는 아래 "아직 안 구워진 것") 새 렌더러로 구워진다. const all = listPayloads(); const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs])); - await prerender(all, '기동'); + await seedAssets(); + + // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 + // 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다. + const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file))); + await prerender(unbuilt, '아직 안 구워진 것'); if (ONCE) return; diff --git a/solution/site/src/entry-client.tsx b/solution/site/src/entry-client.tsx index 2d935a7..d9634f0 100644 --- a/solution/site/src/entry-client.tsx +++ b/solution/site/src/entry-client.tsx @@ -1,4 +1,4 @@ -import {StrictMode} from 'react'; +import {StrictMode, useEffect} from 'react'; import {createRoot, hydrateRoot} from 'react-dom/client'; import type {SitePayload} from '@o2o/shared'; import {App} from './App'; @@ -15,6 +15,22 @@ declare global { const container = document.getElementById('root')!; const injected = window.__SITE_PAYLOAD__; +/** + * 미리보기를 띄운 빌더에게 "이제 그림이 나왔다" 고 알린다. + * + * ★ 빌더는 iframe 의 `load` 만으로는 이 시점을 알 수 없다. `load` 는 **셸이 뜬** 순간이고, + * 그 뒤에 payload fetch + 웹폰트 대기(최대 2.5s) + `document.fonts.ready` 가 남아 있다. + * 그 사이 iframe 은 흰 화면인데, load 를 완료로 읽으면 로딩 표시가 바로 사라져 + * 사장님은 "다 됐다는데 아무것도 없는" 화면을 본다. + * ★ 두 번의 rAF 를 기다린다 — 첫 프레임은 React 가 DOM 을 붙인 직후라 아직 그려지기 전이다. + * ★ 실패해도 보낸다(ok=false). 안 보내면 빌더의 로딩 표시가 영영 안 걷힌다. + */ +function signalPreviewPainted(ok: boolean) { + if (window.parent === window) return; + const post = () => window.parent.postMessage({type: 'o2o:preview-painted', ok}, window.location.origin); + requestAnimationFrame(() => requestAnimationFrame(post)); +} + /** * 빌더 미리보기(iframe)가 여는 셸에서만 쓰는 경로 — `/preview?placeId=…`. * @@ -30,6 +46,11 @@ const injected = window.__SITE_PAYLOAD__; * 떨어진다. 실측(2026-09-09): 간판체(Gugi)가 안 실려 픽셀 차이가 92% 였는데 * 지오메트리(header·hero·h1 위치)는 발행본과 완전히 같았다. */ +function PreviewReady() { + useEffect(() => signalPreviewPainted(true), []); + return null; +} + async function renderPreview(placeId: string) { const token = (() => { try { @@ -68,8 +89,10 @@ async function renderPreview(placeId: string) { createRoot(container).render( <StrictMode> <App payload={payload} /> + <PreviewReady /> </StrictMode>, ); + signalPreviewPainted(true); } const previewPlaceId = new URLSearchParams(window.location.search).get('placeId'); @@ -87,6 +110,7 @@ if (injected) { void renderPreview(previewPlaceId).catch((ex: unknown) => { // 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다. container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`; + signalPreviewPainted(false); }); } else { // 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다. diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index 6e0805d..38fb5f9 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -99,6 +99,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { publishedAt: '2026-08-20T10:00:00+09:00', updatedAt: UPDATED_AT, version: 3, + publish: true, }, place: { diff --git a/solution/site/src/layouts/editorial/Hero.tsx b/solution/site/src/layouts/editorial/Hero.tsx index 3619dbb..e561b26 100644 --- a/solution/site/src/layouts/editorial/Hero.tsx +++ b/solution/site/src/layouts/editorial/Hero.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {useSite} from '@site/lib/site-context'; import {primaryImage} from '@site/seo/meta'; import { @@ -97,7 +98,7 @@ export function Hero() { <div className="col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8"> {tagline && ( <p className="measure font-serif text-[length:var(--fs-lead)] leading-relaxed"> - {tagline} + <HeroCatchphrase>{tagline}</HeroCatchphrase> </p> )} 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<string, string> = { songs: 'songs', daily: 'daily', chronicle: 'chronicle', + reading: 'reading', people: 'people', quiz: 'quiz', postcard: 'postcard', @@ -84,6 +85,7 @@ const FALLBACK_LABEL: Record<string, string> = { songs: '노래', daily: '일력', chronicle: '연표', + reading: '읽기', people: '인물', quiz: '퀴즈', postcard: '엽서', diff --git a/solution/site/src/layouts/oasi/Shell.tsx b/solution/site/src/layouts/oasi/Shell.tsx index a40d09d..c88e256 100644 --- a/solution/site/src/layouts/oasi/Shell.tsx +++ b/solution/site/src/layouts/oasi/Shell.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import type {CSSProperties, ReactNode} from 'react'; import {isoDate} from '@site/lib/format'; import {useSite} from '@site/lib/site-context'; @@ -326,7 +327,7 @@ export function Shell({children}: {children: ReactNode}) { )} {slogan && ( <p className="text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right"> - {slogan} + <HeroCatchphrase>{slogan}</HeroCatchphrase> </p> )} </div> diff --git a/solution/site/src/layouts/pastel/Hero.tsx b/solution/site/src/layouts/pastel/Hero.tsx index 5e38d7a..84c3010 100644 --- a/solution/site/src/layouts/pastel/Hero.tsx +++ b/solution/site/src/layouts/pastel/Hero.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {useSite} from '@site/lib/site-context'; import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive'; @@ -87,7 +88,7 @@ export function Hero() { {line && ( <p className="measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80"> - {line} + <HeroCatchphrase>{line}</HeroCatchphrase> </p> )} diff --git a/solution/site/src/layouts/reservation/Hero.tsx b/solution/site/src/layouts/reservation/Hero.tsx index 96df825..7dcb2aa 100644 --- a/solution/site/src/layouts/reservation/Hero.tsx +++ b/solution/site/src/layouts/reservation/Hero.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {useSite} from '@site/lib/site-context'; import {bookingLabel, bookingLinks, unitSpec} from '@site/lib/derive'; @@ -275,7 +276,7 @@ export function Hero() { // 이름이 다 선 뒤에 한 박자 쉬고 따라온다. 동시에 뜨면 두 줄이 한 덩어리로 보인다. style={{animationDelay: '0.3s'}} > - {subline} + <HeroCatchphrase>{subline}</HeroCatchphrase> </p> )} diff --git a/solution/site/src/layouts/studio/Shell.tsx b/solution/site/src/layouts/studio/Shell.tsx index 583d475..94a610c 100644 --- a/solution/site/src/layouts/studio/Shell.tsx +++ b/solution/site/src/layouts/studio/Shell.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import type {ReactNode} from 'react'; import {useSite} from '@site/lib/site-context'; import { @@ -83,7 +84,7 @@ export function Shell({children}: {children: ReactNode}) { {(narrative.tagline ?? narrative.heroSubline) && ( <p className="text-muted mt-3 text-[length:var(--fs-sm)]" style={{lineHeight: 1.7}}> - {narrative.tagline ?? narrative.heroSubline} + <HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase> </p> )} diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 44b5510..dbd4907 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -28,6 +28,7 @@ export {publicLinks}; */ export interface InfoRow { + key?: string; label: string; value: string; /** 부가 설명. 출처가 아니라 사장님이 붙인 보충 문구. */ @@ -39,6 +40,7 @@ export function essentialRows(payload: SitePayload): InfoRow[] { return selectPublishable(payload.facts) .filter((fact) => fact.scope === 'place') .map((fact) => ({ + key: fact.key, label: displayFactLabel(fact), value: displayValue(fact, payload.facts), })) @@ -46,6 +48,8 @@ export function essentialRows(payload: SitePayload): InfoRow[] { } function displayValue(fact: FactEntry, all: FactEntry[]): string { + // 두 이용 정보 영역은 같은 소개 요약을 쓴다. 상세 소개와 다른 사실은 원문을 보존한다. + if (fact.key === 'intro' && fact.summary?.trim()) return fact.summary.trim(); const text = factText(all, fact.key); if (!text) return ''; if (fact.type !== 'bool') return text; @@ -611,7 +615,6 @@ export function stayBookingView(payload: SitePayload): StayBookingView | null { const empty = view.offers.length === 0 && - view.notices.length === 0 && view.links.length === 0 && view.contacts.length === 0 && !view.phone; diff --git a/solution/site/src/lib/format.ts b/solution/site/src/lib/format.ts index 3710084..0f3ace5 100644 --- a/solution/site/src/lib/format.ts +++ b/solution/site/src/lib/format.ts @@ -34,6 +34,16 @@ export function naverSearchUrl(query: string): string { return `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`; } +/** + * 네이버지도 검색으로 보내는 링크. + * + * ★ 주변 맛집·명소 카드의 "검색으로 열기"는 여기로 보낸다 (2026-09-15, 사장님 지시) — + * 손님이 카드를 누르는 목적이 "이 가게가 어디 있나"라, 웹 검색보다 지도가 바로 답이다. + */ +export function naverMapSearchUrl(query: string): string { + return `https://map.naver.com/p/search/${encodeURIComponent(query)}`; +} + /** * 유튜브 **검색** 주소. * diff --git a/solution/site/src/lib/lodging-catchphrases.json b/solution/site/src/lib/lodging-catchphrases.json new file mode 100644 index 0000000..3ec33a9 --- /dev/null +++ b/solution/site/src/lib/lodging-catchphrases.json @@ -0,0 +1,12 @@ +[ + "잠시 걸음을 멈추고 온전한 쉼을 마주하는 시간", + "바쁜 일상을 내려놓고 나를 위한 여유를 담아갑니다", + "여행의 길목에서 만나는 조용하고 편안한 머무름", + "아무것도 하지 않아도 편안해지는 오늘의 휴식", + "낯선 곳에서 느끼는 다정한 온기와 작은 쉼표", + "지친 하루의 끝에 찾아오는 나만의 따뜻한 여백", + "천천히 흘러가는 시간 속에서 마주하는 깊은 휴식", + "스쳐 가는 여행 속에서 오래 기억될 아늑한 순간", + "복잡한 생각은 잠시 잊고 편안하게 쉬어가세요", + "작은 쉼이 모여 내일을 살아갈 따뜻한 힘이 됩니다" +] diff --git a/solution/site/src/lib/postcard-canvas.ts b/solution/site/src/lib/postcard-canvas.ts new file mode 100644 index 0000000..6a072c3 --- /dev/null +++ b/solution/site/src/lib/postcard-canvas.ts @@ -0,0 +1,256 @@ +/** + * 엽서 한 장을 캔버스에 그린다. + * + * ★ 시연본이 먼저다. `solution/site/scripts/mockup/inject.js` 의 `postcardMaker` 가 이 그림을 + * 먼저 만들었고(2026-09-14 대표 확정), 여기는 그 규칙을 발행본으로 옮긴 것이다. + * 여백 56 · 우표 칸 190 · 최대 4줄 같은 값은 두 곳에 같이 있다 — 하나를 고치면 같이 고친다. + * ★ 그리기만 한다. 사진 고르기·글자 입력·공유는 부르는 쪽(PostcardMakerSection)이 맡는다 — + * 캔버스 규칙과 화면 조작을 한 파일에 섞으면 둘 다 못 읽게 된다. + */ + +/** 정사각형 1080 — 공유 시트에 올라가는 이미지의 1:1 관례와 같다. */ +export const POSTCARD_SIZE = 1080; +/** 문구는 넉 줄까지만 (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). */ +export const MAX_LINES = 4; + +const MARGIN = 56; +const STAMP_COL_W = 190; +const INK = '#1b1a15'; +const PAPER = '#efe7d3'; +const ACCENT = '#bf2f1b'; + +/** 문구가 들어갈 칸의 폭. 입력을 막을 때도 이 값으로 미리 재야 화면과 그림이 갈리지 않는다. */ +export function textColumnWidth(size: number = POSTCARD_SIZE): number { + return size - MARGIN - STAMP_COL_W - MARGIN - 24; +} + +/** + * 캔버스는 CSS 처럼 저절로 줄을 안 바꾼다 — 폭에 맞춰 손으로 접는다. + * ★ 엔터는 그대로 줄바꿈이다. 먼저 `\n` 으로 문단을 가르고 문단만 접는다 — + * 공백과 같이 묶어 쪼개면 손님이 누른 엔터가 사라진다. + * ★ 공백 없는 긴 글자열(자음만 이어친 것)은 글자 단위로도 접는다. 안 그러면 우표 칸으로 넘친다. + */ +export function wrapLines(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const lines: string[] = []; + text.split('\n').forEach((para) => { + if (para.trim() === '') { + lines.push(''); + return; + } + let line = ''; + para.split(' ').forEach((word) => { + const 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; + } + let chunk = ''; + for (const ch of word) { + const next = chunk + ch; + if (ctx.measureText(next).width > maxWidth && chunk) { + lines.push(chunk); + chunk = ch; + } else { + chunk = next; + } + } + line = chunk; + }); + if (line) lines.push(line); + }); + return lines; +} + +/** + * 소인(우표 옆 회전 원) 안 상호명. + * ★ 업소 이름을 박아 두지 않는다 — 숙소든 카페든 같은 코드가 그린다. 콤마·공백에서 먼저 끊고, + * 그래도 넓으면 글자 단위로 접는다("스테이,머뭄" 처럼 공백 없는 상호가 있다). + */ +function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: number): string[] { + const lines: string[] = []; + let line = ''; + name.split(/[,\s]+/).filter(Boolean).forEach((part) => { + const 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; + } + let chunk = ''; + for (const ch of part) { + const next = chunk + ch; + if (ctx.measureText(next).width > maxWidth && chunk) { + lines.push(chunk); + chunk = ch; + } else { + chunk = next; + } + } + line = chunk; + }); + if (line) lines.push(line); + return lines; +} + +/** + * 사진 한 장. 돌려주는 `exportable` 이 **이 엽서를 파일로 뽑을 수 있는지**를 가른다. + * + * ★ 캔버스는 남의 도메인 사진을 그리면 오염돼서(tainted) `toBlob` 이 막힌다 — 미리보기는 + * 보이는데 저장·공유만 SecurityError 로 죽는, 눈으로는 못 찾는 종류다. + * ★ CORS 로 받아오면 안 오염된다. 그래서 먼저 `crossOrigin="anonymous"` 로 받아 본다. + * 실측(2026-09-14): 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 그쪽은 + * `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 이 시도는 실패하고, + * **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다). + * ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s/<slug>/img/` 에 두고 + * payload 의 주소를 우리 오리진으로 바꾼다(`scripts/prerender.ts` `mirrorMedia`). + * 같은 오리진이면 오염되지 않는다. **다만 그건 다시 굽는 사이트에만 적용된다** — + * 이미 나가 있는 사이트는 사장님이 재발행할 때까지 옛 주소를 문다. + * ★ 그 사이를 **중계**가 메운다(`backend/router/v1/media/relay.py`). 두 번째 시도가 그것이다: + * 같은 오리진의 `/v1/image/relay?url=…` 로 바이트를 받아 오면 브라우저는 그걸 우리 사진으로 + * 본다. 세 번째(맨손 로드)는 중계까지 막혔을 때의 미리보기 전용이다. + * ★ 순서를 바꾸지 않는다 — 이미 우리 자리에 있는 사진은 첫 시도에서 끝나야 한다. + * 중계를 먼저 걸면 우리 사진까지 서버를 한 번 더 거친다. + */ +const RELAY = '/v1/image/relay?url='; + +async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> { + const tryLoad = (src: string, crossOrigin: string | null) => + new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image(); + if (crossOrigin) img.crossOrigin = crossOrigin; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + + try { + return {img: await tryLoad(url, 'anonymous'), exportable: true}; + } catch { + // 남의 CDN 이 CORS 를 안 줬다 — 우리 서버를 거쳐 같은 오리진으로 받아 본다. + } + if (/^https?:\/\//i.test(url)) { + try { + return {img: await tryLoad(`${RELAY}${encodeURIComponent(url)}`, 'anonymous'), exportable: true}; + } catch { + // 중계가 없는 환경(정적 호스팅만 있는 미리보기)이거나 allowlist 밖이다. + } + } + return {img: await tryLoad(url, null), exportable: false}; +} + +/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */ +function serifFamily(): string { + const probe = document.querySelector('.serif'); + const family = probe ? getComputedStyle(probe).fontFamily : ''; + return family || 'serif'; +} + +/** 돌려주는 값: 이 그림을 파일로 뽑을 수 있는가(사진이 오염시키지 않았는가). */ +export async function drawPostcard( + canvas: HTMLCanvasElement, + {photoUrl, text, placeName, region}: {photoUrl: string; text: string; placeName: string; region: string}, +): Promise<boolean> { + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + const size = canvas.width; + const serif = serifFamily(); + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = PAPER; + ctx.fillRect(0, 0, size, size); + + const {img, exportable} = await loadImage(photoUrl); + + // 위 — 사진(실제 카드 앞면과 같은 3:2) + const photoW = size - MARGIN * 2; + const photoH = photoW / 1.5; + const scale = Math.max(photoW / img.width, photoH / img.height); + const w = img.width * scale; + const h = img.height * scale; + ctx.save(); + ctx.beginPath(); + ctx.rect(MARGIN, MARGIN, photoW, photoH); + ctx.clip(); + ctx.drawImage(img, MARGIN + (photoW - w) / 2, MARGIN + (photoH - h) / 2, w, h); + ctx.restore(); + ctx.strokeStyle = 'rgba(27,26,21,0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(MARGIN + 0.5, MARGIN + 0.5, photoW - 1, photoH - 1); + + // 아래 — 괘선으로 가른 두 칸(문구 | 우표) + const bodyY = MARGIN + photoH + 36; + const dividerX = size - MARGIN - STAMP_COL_W; + const textColW = textColumnWidth(size); + ctx.strokeStyle = 'rgba(27,26,21,0.35)'; + ctx.beginPath(); + ctx.moveTo(dividerX, bodyY); + ctx.lineTo(dividerX, size - MARGIN - 46); + ctx.stroke(); + + // 왼쪽 — 손님이 쓴 한 마디 + ctx.fillStyle = INK; + ctx.font = `600 46px ${serif}`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + const wrapped = wrapLines(ctx, `“${text.trim() || '오늘, 이 숙소에서 하루를 보냅니다.'}”`, textColW); + // 입력에서 이미 막지만 여기서도 자른다 — 붙여넣기 같은 다른 경로로 들어와도 카드가 안 깨지게. + const lines = wrapped.slice(0, MAX_LINES); + if (wrapped.length > MAX_LINES) { + let last = lines[MAX_LINES - 1]; + while (last.length && ctx.measureText(`${last}…`).width > textColW) last = last.slice(0, -1); + lines[MAX_LINES - 1] = `${last}…`; + } + lines.forEach((line, i) => ctx.fillText(line, MARGIN, bodyY + 50 + i * 60)); + + // 오른쪽 — 우표 칸과 소인 + const stampCx = dividerX + STAMP_COL_W / 2; + 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 ${serif}`; + 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 ${serif}`; + const nameLines = stampLines(ctx, placeName, 100).slice(0, 2); + const startY = 8 - ((nameLines.length - 1) * 26) / 2; + nameLines.forEach((line, i) => ctx.fillText(line, 0, startY + i * 26)); + ctx.restore(); + + // 맨 아래 — 점선과 서명 줄 + const 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 ${serif}`; + ctx.textAlign = 'left'; + ctx.fillText(`${placeName} · ${region}`, MARGIN, footY + 6); + return exportable; +} diff --git a/solution/site/src/lib/use-weather-note.ts b/solution/site/src/lib/use-weather-note.ts new file mode 100644 index 0000000..e0c5beb --- /dev/null +++ b/solution/site/src/lib/use-weather-note.ts @@ -0,0 +1,28 @@ +import {useEffect, useState} from 'react'; + +/** 첫 렌더는 고정해 SSR과 맞추고, 이후에는 한 바퀴 안에서 중복 없이 돌린다. */ +export function useWeatherNote(lines: string[] | undefined, fallback?: string) { + const [selection, setSelection] = useState<{lines: string[]; index: number}>(); + useEffect(() => { + if (!lines?.length) return; + let remaining: number[] = []; + let previous = -1; + const next = () => { + if (!remaining.length) remaining = lines.map((_, index) => index); + const choices = remaining.filter((index) => index !== previous); + const available = choices.length ? choices : remaining; + const index = available[Math.floor(Math.random() * available.length)]; + remaining = remaining.filter((value) => value !== index); + previous = index; + setSelection({lines, index}); + }; + // effect 안의 동기 setState 대신 첫 타이머에서 무작위 문구로 전환한다. + const first = window.setTimeout(next, 0); + const timer = window.setInterval(next, 20_000); + return () => { + window.clearTimeout(first); + window.clearInterval(timer); + }; + }, [lines]); + return lines?.[selection?.lines === lines ? selection.index : 0] ?? fallback; +} diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index 46461a0..d973eb8 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -2,7 +2,6 @@ import {PlaceCategory} from '@o2o/shared'; import { AboutSection, SocialPostsSection, - AnswerBlock, BookingSection, EssentialInfoSection, ExhibitionSection, @@ -15,6 +14,7 @@ import { LocalGuideSection, WeatherSection, LocationSection, + PostcardMakerSection, SpaceSection, UnitsSection, StayBookingSection, @@ -26,12 +26,7 @@ import {hasSection, isSectionEnabled} from '@site/lib/derive'; * 홈. * * 섹션 순서는 관리자 에디터가 정한 것(theme.sections)을 따른다. - * ★ AnswerBlock('예약 전 확인')은 **소개 바로 다음**이다 (2026-09-04, 사장님 지시). - * 전에는 히어로 바로 밑이었다. 첫 화면에서 상호를 본 손님이 곧바로 체크인 시각·환불 규정 - * 표를 만나면 "여기가 어떤 집인가"를 못 읽고 값부터 본다 — 소개 한 문단을 먼저 세운다. - * 순서 설정과 무관하게 이 자리에 고정인 것은 그대로다. AI 검색이 답을 뽑아 가는 자리라 - * 더 아래로 내려가면 인용률이 떨어진다. - * ★ 소개가 꺼져 있으면 히어로 바로 다음으로 올라온다 — 빈자리를 기다리지 않는다. + * 예약 전 확인은 EssentialInfoSection 한 곳에 모은다(2026-09-15 요청). * * ★ variantId 는 아직 해석하지 않는다. * `SectionSetting.variantId` 에 사장님이 [디자인] 탭에서 고른 배리에이션 키 @@ -84,10 +79,23 @@ export function HomePage() { const rendered = new Set<string>(); + /* + * 엽서 쓰기가 설 자리 — **계절별 축제 바로 앞**이다 (2026-09-14 대표: "이건 축제소개 전에 + * 섹션으로 빼줘", 2026-09-15: "목업처럼 순서 바꿔"). + * + * ★ 섹션 설정(`theme.sections`)에 자리가 없는 기능이라 목록을 따라 그릴 수 없다. 그래서 + * **축제 바로 앞에 끼워 넣고**, 축제가 꺼져 있거나 없는 사이트에서는 맨 뒤로 보낸다 — + * 자리를 못 찾았다고 기능을 통째로 빠뜨리지 않는다. + * ★ 예전에는 목록을 다 그린 **뒤** 맨 아래에 뒀다. 그러면 FAQ·예약 안내보다도 밑이라 + * 시연본과 순서가 달랐다(시연본은 축제 앞). + */ + const festivalFirst = payload.theme.sections.some( + (section) => section.enabled && section.id === 'festival' && SECTION_COMPONENTS.festival, + ); + return ( <main className="w-full flex-1"> <HeroSection /> - {!isSectionEnabled(payload, 'intro') && <AnswerBlock />} {payload.theme.sections .filter((section) => section.enabled && section.id !== 'hero' && section.id !== 'social') @@ -106,12 +114,16 @@ export function HomePage() { * 화면 id(`gallery`)와 설정 id(`photos`)가 달라서 필요하다. */ <div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}> + {/* 사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */} + {section.id === 'festival' && <PostcardMakerSection />} <Component /> - {section.id === 'intro' && <AnswerBlock />} </div> ); })} + {/* 축제 섹션이 없는 사이트 — 앞에 끼울 자리가 없었으니 여기서 낸다. */} + {!festivalFirst && <PostcardMakerSection />} + {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {!isSectionEnabled(payload, 'map') && <LocationSection />} diff --git a/solution/site/src/publish-version.test.ts b/solution/site/src/publish-version.test.ts new file mode 100644 index 0000000..2b3554f --- /dev/null +++ b/solution/site/src/publish-version.test.ts @@ -0,0 +1,66 @@ +import {afterEach, expect, it} from 'vitest'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdtempSync, mkdirSync, readFileSync, readlinkSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {MOONLIGHT_STAY_PAYLOAD} from './fixtures/moonlight-stay'; + +const scratch: string[] = []; +afterEach(() => scratch.splice(0).forEach((dir) => rmSync(dir, {recursive: true, force: true}))); + +it('검수 전 비공개·버전 전환·롤백·실패 격리·목업 보존', () => { + const root = mkdtempSync(join(tmpdir(), 'web4ai-version-test-')); + scratch.push(root); + const out = join(root, 'out'); + const payloadFile = join(root, 'test-pension.json'); + const run = (...args: string[]) => spawnSync(process.execPath, [ + resolve('dist/prerender/prerender.js'), `--out=${out}`, ...args, + ], {encoding: 'utf8', timeout: 20000}); + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.site.slug = 'test-pension'; + payload.site.basePath = '/s/test-pension'; + payload.site.publish = true; + payload.site.version = 1; + payload.media.forEach((media) => {media.url = `/test/${media.mediaId}.jpg`;}); + const save = () => writeFileSync(payloadFile, JSON.stringify(payload)); + const stage = () => run(`--payload=${payloadFile}`, '--stage-only'); + + mkdirSync(join(out, 's/stay'), {recursive: true}); + mkdirSync(join(out, 'assets'), {recursive: true}); + writeFileSync(join(out, 's/stay/index.html'), '<link href="/assets/mock.css">protected'); + writeFileSync(join(out, 'assets/mock.css'), 'mock css'); + save(); + let result = stage(); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(out, 's/test-pension'))).toBe(false); + result = run('--activate=test-pension:1'); + expect(result.status, result.stderr).toBe(0); + const v1 = readFileSync(join(out, 's/test-pension/index.html'), 'utf8'); + expect(readlinkSync(join(out, 's/test-pension'))).toBe('../versions/test-pension/1'); + expect(readFileSync(join(out, 'sitemap.xml'), 'utf8')).toContain('/s/test-pension'); + + payload.site.version = 2; + payload.place.name = '새 이름 테스트 펜션'; + save(); + result = stage(); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(out, 's/test-pension/index.html'), 'utf8')).toBe(v1); + expect(run('--activate=test-pension:2').status).toBe(0); + expect(readFileSync(join(out, 's/test-pension/index.html'), 'utf8')).toContain('새 이름 테스트 펜션'); + + payload.site.version = 1; + save(); + expect(stage().status).toBe(0); + expect(run('--activate=test-pension:1').status).toBe(0); + expect(readFileSync(join(out, 's/test-pension/index.html'), 'utf8')).toBe(v1); + payload.site.version = 3; + payload.schemaVersion = 99 as 1; + save(); + expect(stage().status).not.toBe(0); + expect(run('--activate=test-pension:3').status).not.toBe(0); + expect(readFileSync(join(out, 's/test-pension/index.html'), 'utf8')).toBe(v1); + expect(run('--activate=stay:1').status).not.toBe(0); + expect(run('--seed-assets').status).toBe(0); + expect(readFileSync(join(out, 's/stay/index.html'), 'utf8')).toBe('<link href="/assets/mock.css">protected'); + expect(readFileSync(join(out, 'assets/mock.css'), 'utf8')).toBe('mock css'); +}, 60000); diff --git a/solution/site/src/sections/AnswerBlock.tsx b/solution/site/src/sections/AnswerBlock.tsx index f707642..62c89fd 100644 --- a/solution/site/src/sections/AnswerBlock.tsx +++ b/solution/site/src/sections/AnswerBlock.tsx @@ -1,3 +1,4 @@ +import {selectPublishable} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {factualSummary} from '@site/seo/meta'; import {essentialRows} from '@site/lib/derive'; @@ -17,7 +18,8 @@ import {formatKoreanDate} from '@site/lib/format'; export function AnswerBlock() { const payload = useSite(); const rows = essentialRows(payload).slice(0, 6); - const summary = payload.narrative.summary ?? factualSummary(payload); + const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro'); + const summary = intro?.summary?.trim() || payload.narrative.summary || factualSummary(payload); if (rows.length === 0 && !summary) return null; diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx index c5de383..a289985 100644 --- a/solution/site/src/sections/EssentialInfoSection.tsx +++ b/solution/site/src/sections/EssentialInfoSection.tsx @@ -1,6 +1,8 @@ import {Phone} from 'lucide-react'; +import {selectPublishable} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; -import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive'; +import {bookingActionLabel, bookingLinks, essentialRows} from '@site/lib/derive'; +import {factualSummary} from '@site/seo/meta'; import {formatKoreanDate} from '@site/lib/format'; import type {InfoRow} from '@site/lib/derive'; import {Section} from '@site/lib/ui'; @@ -8,7 +10,7 @@ import {Section} from '@site/lib/ui'; /** * 이용 정보 표 전체. * - * AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다. + * 예약 전 확인은 이 섹션 한 곳에서 확인된 항목 전체를 낸다. * ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면 * 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다. * @@ -43,19 +45,47 @@ const RULE_LABELS = new Set([ export function EssentialInfoSection() { const payload = useSite(); const rows = essentialRows(payload); + const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro'); + const summary = intro?.summary?.trim() || payload.narrative.summary || factualSummary(payload); + const guides = payload.links.filter((link) => link.confirmed && + [link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim())); + const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []); + const mergedRows = [...rows]; + const seen = new Set(rows.map((row) => row.key)); + for (const field of structured) { + // 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다. + if (!seen.has(field.key)) { + mergedRows.push(field); + seen.add(field.key); + } + } + const isRule = (row: InfoRow) => { + if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false; + return RULE_LABELS.has(row.label) || structured.some((field) => field.key === row.key && field.group === 'rules'); + }; const hiddenCount = payload.facts.filter( (fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label), ).length; - if (rows.length === 0) return null; + if (rows.length === 0 && guides.length === 0) return null; /** * 규정과 나머지를 가른다. * * ★ 순서를 뒤집지 않는다 — 규정이 먼저다. 예약 전에 알아야 클레임이 안 난다. */ - const ruleRows = rows.filter((row) => RULE_LABELS.has(row.label)); - const otherRows = rows.filter((row) => !RULE_LABELS.has(row.label)); + const ruleRows = [ + ...mergedRows.filter(isRule), + ...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.policy).map((link) => ({ + label: 'NOL 이용 안내', value: link.stayGuide?.policy ?? '', + })), + ]; + const otherRows = [ + ...mergedRows.filter((row) => !isRule(row)), + ...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.service).map((link) => ({ + label: 'NOL 시설 안내', value: link.stayGuide?.service ?? '', + })), + ]; return ( <Section @@ -71,15 +101,21 @@ export function EssentialInfoSection() { <span> {hiddenCount > 0 ? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.` - : '모든 항목이 사업자 확인을 거쳤습니다.'} + : '등록 정보와 수집한 안내를 기준으로 표시합니다.'} </span> <span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span> </span> } > <div className="space-y-8"> + {summary && <p className="measure text-[length:var(--fs-body)] leading-relaxed">{summary}</p>} {ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />} {otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />} + {guides.filter((link) => link.stayGuide?.reservation).map((link) => ( + <Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[ + {label: '예약 공지', value: link.stayGuide?.reservation ?? ''}, + ]} /> + ))} <BookingRow /> </div> </Section> @@ -112,16 +148,16 @@ function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis {/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */} <dl className="divide-line divide-y"> - {rows.map((row) => ( + {rows.map((row, index) => ( <div - key={row.label} + key={`${row.label}-${index}`} className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6" > <dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0"> {row.label} </dt> {/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */} - <dd className="measure text-[length:var(--fs-sm)] font-semibold"> + <dd className="measure min-w-0 whitespace-pre-line break-words text-[length:var(--fs-sm)] font-semibold"> {row.value} {row.note && <span className="text-muted mt-0.5 block font-normal">{row.note}</span>} </dd> diff --git a/solution/site/src/sections/FaqSection.tsx b/solution/site/src/sections/FaqSection.tsx index 17d491d..2ab6310 100644 --- a/solution/site/src/sections/FaqSection.tsx +++ b/solution/site/src/sections/FaqSection.tsx @@ -1,3 +1,4 @@ +import {SourceType} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {faqList} from '@site/lib/derive'; import {Section} from '@site/lib/ui'; @@ -18,12 +19,15 @@ export function FaqSection() { if (faqs.length === 0) return null; + // ★ 문의 안내(TEMPLATE)가 섞이면 "모두 사업자가 확인한 내용" 이 거짓이 된다 — 그땐 설명을 달지 않는다. + const hasInquiry = faqs.some((faq) => faq.sourceType === SourceType.TEMPLATE); + return ( <Section id="faq" tone="alt" title="자주 묻는 질문" - lead="아래 답변은 모두 사업자가 확인한 내용입니다." + lead={hasInquiry ? undefined : '아래 답변은 모두 사업자가 확인한 내용입니다.'} > <div className={faqs.length > 4 ? 'grid gap-3 lg:grid-cols-2 lg:gap-4' : 'space-y-3'}> {faqs.map((faq, index) => ( diff --git a/solution/site/src/sections/HeroCatchphrase.tsx b/solution/site/src/sections/HeroCatchphrase.tsx new file mode 100644 index 0000000..33a4e9c --- /dev/null +++ b/solution/site/src/sections/HeroCatchphrase.tsx @@ -0,0 +1,34 @@ +import {useEffect, useState, type CSSProperties} from 'react'; +import {PlaceCategory} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import lines from '@site/lib/lodging-catchphrases.json'; +import './hero-catchphrase.css'; + +export function HeroCatchphrase({children}: {children?: string}) { + const {place} = useSite(); + const lodging = place.category === PlaceCategory.LODGING; + const [index, setIndex] = useState(0); + // 업장 소개는 첫 줄에 고정하고, 공통 감성 문구만 둘째 줄에서 순환한다. + const phrases = lines; + useEffect(() => { + if (!lodging) return; + const motion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const timer = window.setInterval(() => { + if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length); + }, 6000); + return () => window.clearInterval(timer); + }, [lodging, phrases.length]); + + if (!lodging) return children; + const text = phrases[index % phrases.length]; + return ( + <span className="hero-catchphrase"> + {children && <span className="hero-catchphrase-fixed">{children}</span>} + <span className="w4d-line w4d-line--in" key={index}> + {text.split(/\s+/).map((word, i) => ( + <span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span> + ))} + </span> + </span> + ); +} diff --git a/solution/site/src/sections/HeroPension.tsx b/solution/site/src/sections/HeroPension.tsx index b3bd620..5a01dcf 100644 --- a/solution/site/src/sections/HeroPension.tsx +++ b/solution/site/src/sections/HeroPension.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {useCallback, useEffect, useRef, useState} from 'react'; import {ChevronLeft, ChevronRight, Phone} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; @@ -124,7 +125,7 @@ export function HeroPension() { </h1> {(narrative.tagline ?? narrative.heroSubline) && ( <p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90"> - {narrative.tagline ?? narrative.heroSubline} + <HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase> </p> )} </div> diff --git a/solution/site/src/sections/HeroSection.tsx b/solution/site/src/sections/HeroSection.tsx index 792b9bd..6a13acd 100644 --- a/solution/site/src/sections/HeroSection.tsx +++ b/solution/site/src/sections/HeroSection.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {ChevronDown, MapPin} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; import {primaryImage} from '@site/seo/meta'; @@ -99,7 +100,7 @@ export function HeroSection() { {/* ★ 한 줄이 없으면 요약이라도 세운다 — 이름만 덩그러니 있는 첫 화면은 안내가 아니다. */} {(narrative.tagline ?? narrative.heroSubline ?? narrative.summary) && ( <p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85"> - {narrative.tagline ?? narrative.heroSubline ?? narrative.summary} + <HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase> </p> )} diff --git a/solution/site/src/sections/HeroSplit.tsx b/solution/site/src/sections/HeroSplit.tsx index f9ec36d..8c61cc0 100644 --- a/solution/site/src/sections/HeroSplit.tsx +++ b/solution/site/src/sections/HeroSplit.tsx @@ -1,3 +1,4 @@ +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {Phone} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive'; @@ -59,7 +60,7 @@ export function HeroSplit() { {place.name} </h1> <p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80"> - {narrative.tagline ?? narrative.heroSubline ?? narrative.summary} + <HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase> </p> </div> diff --git a/solution/site/src/sections/LocalGuideSection.tsx b/solution/site/src/sections/LocalGuideSection.tsx index da2f525..f28ce98 100644 --- a/solution/site/src/sections/LocalGuideSection.tsx +++ b/solution/site/src/sections/LocalGuideSection.tsx @@ -2,7 +2,7 @@ import {ArrowUpRight, MapPin, Utensils} from 'lucide-react'; import type {LocalPlace} from '@o2o/shared'; import {useState} from 'react'; import {useSite} from '@site/lib/site-context'; -import {formatKoreanDate, naverSearchUrl} from '@site/lib/format'; +import {formatKoreanDate, naverMapSearchUrl} from '@site/lib/format'; import {Carousel, CarouselSlide, Section} from '@site/lib/ui'; /** @@ -47,6 +47,16 @@ function meters(text: string | undefined): number { return Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1); } +function distanceOf(place: LocalPlace): number { + // 숫자가 없는 옛 payload만 표기 문자열로 복원한다. + if (place.distanceMeters != null) { + return Number.isFinite(place.distanceMeters) && place.distanceMeters >= 0 + ? place.distanceMeters + : Infinity; + } + return meters(place.distanceText); +} + /** * 주변 정보. * @@ -62,17 +72,20 @@ export function LocalGuideSection() { const {local} = payload; // ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라 // 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다. - const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0; + // ★ 맛집은 사진이 없으면 아예 목록에서 뺀다 (2026-09-15, 사장님 지시) — + // 명소는 이름 활자로 대신하는 규칙을 유지하지만, 맛집은 사진 없는 곳을 보여주지 않는다. + const restaurants = local.restaurants.filter((place) => place.imageUrl); + const hasAnything = restaurants.length > 0 || local.attractions.length > 0; const [range, setRange] = useState<string>('all'); if (!hasAnything) return null; - const all = [...local.restaurants, ...local.attractions]; + const all = [...restaurants, ...local.attractions]; const count = (r: (typeof RANGES)[number]) => - all.filter((place) => r.test(meters(place.distanceText))).length; + all.filter((place) => r.test(distanceOf(place))).length; // 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다. const ranges = RANGES.filter((r, i) => i === 0 || (count(r) > 0 && count(r) < all.length)); const active = ranges.find((r) => r.id === range) ?? RANGES[0]; - const within = (place: LocalPlace) => active.test(meters(place.distanceText)); + const within = (place: LocalPlace) => active.test(distanceOf(place)); return ( <Section @@ -116,8 +129,8 @@ export function LocalGuideSection() { )} <div className="space-y-10"> - {local.restaurants.length > 0 && ( - <PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} within={within} /> + {restaurants.length > 0 && ( + <PlaceList title="주변 맛집" icon={Utensils} places={restaurants} within={within} /> )} {local.attractions.length > 0 && ( <PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} /> @@ -171,7 +184,7 @@ function PlaceList({ {shown.map((place) => ( <CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4"> <a - href={naverSearchUrl(place.searchQuery)} + href={naverMapSearchUrl(place.searchQuery)} target="_blank" rel="noopener noreferrer nofollow" className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85" @@ -201,7 +214,7 @@ function PlaceList({ )} {/* 거리는 사진 위에 얹는다 — 손님이 카드에서 제일 먼저 찾는 값이다. ★ 분을 앞에, 거리를 뒤에. 답이 '분'이고 'm' 는 그 근거다. */} - {place.distanceText && ( + {(Number.isFinite(distanceOf(place)) || place.distanceText) && ( <span className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold" style={{ @@ -209,7 +222,7 @@ function PlaceList({ color: 'var(--tpl-bg, #fff)', }} > - {walkText(meters(place.distanceText)) ?? place.distanceText} + {walkText(distanceOf(place)) ?? place.distanceText} <span className="ml-1 font-normal opacity-70">{place.distanceText}</span> </span> )} @@ -217,6 +230,9 @@ function PlaceList({ <span className="flex flex-1 flex-col gap-1 p-3.5"> <span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span> + {place.location && ( + <span className="text-muted text-[length:var(--fs-xs)] break-words">{place.location}</span> + )} {place.description && ( <span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed"> {place.description} @@ -238,17 +254,18 @@ function PlaceList({ .filter((place) => !within(place)) .map((place) => ( <li key={place.name}> - <a href={naverSearchUrl(place.searchQuery)} rel="nofollow"> + <a href={naverMapSearchUrl(place.searchQuery)} rel="nofollow"> {place.name} </a> {place.distanceText && ( <span> {' '} {place.distanceText} - {walkText(meters(place.distanceText)) && ` · ${walkText(meters(place.distanceText))}`} + {walkText(distanceOf(place)) && ` · ${walkText(distanceOf(place))}`} </span> )} {place.description && <p>{place.description}</p>} + {place.location && <p>{place.location}</p>} </li> ))} </ul> diff --git a/solution/site/src/sections/SiteFooter.tsx b/solution/site/src/sections/SiteFooter.tsx index 9ab1b34..61a4add 100644 --- a/solution/site/src/sections/SiteFooter.tsx +++ b/solution/site/src/sections/SiteFooter.tsx @@ -112,6 +112,25 @@ export function SiteFooter() { <time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time> </p> </div> + + {/* + ★ 만든 곳 표기 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈"). + payload 가 아니라 렌더러가 박는다 — 사장님이 어떤 템플릿을 골라도, 어떤 값을 + 비워 두어도 모든 발행본에 같은 자리로 나와야 하는 한 줄이다. + ★ 위 두 칸(사업자 표기·byline)과 같은 활자·불투명도를 쓴다. 이 줄이 눈에 띄면 + 사장님 사이트가 아니라 우리 사이트로 보인다. + */} + <p className="pt-3 text-[length:var(--fs-xs)] opacity-55"> + <a + href="https://www.o2osolution.ai/" + target="_blank" + rel="noopener noreferrer" + className="underline-offset-2 hover:underline" + > + AI O2O + </a> + 의 Web4Ai로 만든 사이트입니다. + </p> </div> </footer> ); diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx index 2174121..4b0c458 100644 --- a/solution/site/src/sections/StayBookingSection.tsx +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -1,4 +1,4 @@ -import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react'; +import {ArrowUpRight, BedDouble, CalendarCheck, Phone} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@site/lib/derive'; import {StayBookingDemo} from './StayBookingDemo'; @@ -31,7 +31,7 @@ export function StayBookingSection() { if (!view) return null; - const {offers, notices, links, contacts, phone} = view; + const {offers, links, contacts, phone} = view; return ( <section @@ -178,31 +178,6 @@ export function StayBookingSection() { {/* ── 날짜 · 시간 선택 (화면 안에서만 도는 목업) ────────── */} <StayBookingDemo /> - {/* ── 예약 전 확인 ────────────────────────────────────── */} - {notices.length > 0 && ( - <div - className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6" - style={{backgroundColor: 'var(--color-surface)'}} - > - <p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5"> - <ShieldCheck className="size-3.5 opacity-50" /> - <span>예약 전 확인</span> - </p> - - {/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */} - <dl className="grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0"> - {notices.map((row) => ( - <div - key={row.label} - className="flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5" - > - <dt className="shrink-0 text-xs opacity-55">{row.label}</dt> - <dd className="text-xs font-semibold sm:max-w-[60%] sm:text-right">{row.value}</dd> - </div> - ))} - </dl> - </div> - )} </div> </section> ); diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 0068efd..7e1010b 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -11,6 +11,7 @@ */ import {useSite} from '@site/lib/site-context'; import {useLiveWeather} from '@site/lib/use-live-weather'; +import {useWeatherNote} from '@site/lib/use-weather-note'; import {weatherBand, weatherMood} from '@site/lib/derive'; import {Card, Section} from '@site/lib/ui'; import './weather.css'; @@ -32,12 +33,12 @@ export function WeatherSection() { longitude: payload.place.longitude, }); - if (!weather) return null; - - const mood = weatherMood(weather.condition); - const band = weatherBand(weather.temperature); + const mood = weatherMood(weather?.condition ?? ''); + const band = weatherBand(weather?.temperature ?? 0); + const notes = payload.local.weather; + const skyKey = weather?.condition === '구름많음' ? '구름많음' : mood; // 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다. - const line = payload.local.weather?.notes?.[mood] ?? weather.note; + const line = useWeatherNote(notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note); /* * ★ 기온 줄 (2026-09-09, 사장님: "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰서") * 하늘 줄은 집 안 이야기(마당·CAFÉ)라 "그래서 오늘 어디를 가지"에는 답하지 않았다. @@ -45,7 +46,8 @@ export function WeatherSection() { * ★ 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 * 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ - const bandLine = payload.local.weather?.tempNotes?.[band]; + const bandLine = useWeatherNote(notes?.tempNoteSets?.[band], notes?.tempNotes?.[band]); + if (!weather) return null; const observed = observedText(weather.observedAt); return ( diff --git a/solution/site/src/sections/hero-catchphrase.css b/solution/site/src/sections/hero-catchphrase.css new file mode 100644 index 0000000..2d6b627 --- /dev/null +++ b/solution/site/src/sections/hero-catchphrase.css @@ -0,0 +1,12 @@ +.hero-catchphrase { display: block; } +.hero-catchphrase-fixed { display: block; } +.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; } +.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; } +.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); } +@keyframes lodging-word-in { + from { opacity: 0; transform: translateY(100%); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .hero-catchphrase .w4d-w > span { animation: none; } +} diff --git a/solution/site/src/sections/hero-catchphrase.test.tsx b/solution/site/src/sections/hero-catchphrase.test.tsx new file mode 100644 index 0000000..c65559e --- /dev/null +++ b/solution/site/src/sections/hero-catchphrase.test.tsx @@ -0,0 +1,27 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {PlaceCategory} from '@o2o/shared'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import lines from '@site/lib/lodging-catchphrases.json'; +import {HeroCatchphrase} from './HeroCatchphrase'; + +it('keeps the original lodging tagline in the static HTML', () => { + const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><HeroCatchphrase>고유문구</HeroCatchphrase></SiteProvider>); + expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>'); + expect(html).toContain('w4d-line--in'); + const text = html.replace(/<[^>]+>/g, ''); + expect(text).toContain('고유문구' + lines[0]); +}); + +it('does not add lodging copy or controls to another category', () => { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.place.category = PlaceCategory.CAFE; + const html = renderToStaticMarkup(<SiteProvider payload={payload}><HeroCatchphrase>카페 소개</HeroCatchphrase></SiteProvider>); + expect(html).toBe('카페 소개'); +}); + +it('ships ten distinct short phrases', () => { + expect(new Set(lines).size).toBe(10); + expect(lines.every((line) => line.length >= 15 && line.length <= 30)).toBe(true); +}); diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index 30d55de..63043f5 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -21,5 +21,5 @@ export {SiteFooter} from './SiteFooter'; export {MobileTabBar} from './MobileTabBar'; // 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data). export {ITEM_SECTIONS} from './items'; - +export {PostcardMakerSection} from './items/PostcardMakerSection'; export {SocialPostsSection} from './SocialPostsSection'; diff --git a/solution/site/src/sections/intro-summary.test.tsx b/solution/site/src/sections/intro-summary.test.tsx new file mode 100644 index 0000000..b847b43 --- /dev/null +++ b/solution/site/src/sections/intro-summary.test.tsx @@ -0,0 +1,45 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {FactStatus, SourceType, type SitePayload} from '@o2o/shared'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {AnswerBlock} from './AnswerBlock'; +import {EssentialInfoSection} from './EssentialInfoSection'; +import {AboutSection} from './AboutSection'; + +function fixture(summary?: string): SitePayload { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.facts = [{ + key: 'intro', label: '숙소 소개', value: '숙소 소개 전체 원문입니다.', summary, + scope: 'place', type: 'text', status: FactStatus.VERIFIED, + sourceType: SourceType.LLM, critical: false, required: false, + }]; + payload.narrative = {about: ['숙소 소개 전체 원문입니다.'], summary: '기존 첫 문장'}; + return payload; +} + +it('shows the same summary in both booking information sections', () => { + const payload = fixture('짧게 요약한 숙소 소개'); + for (const section of [<AnswerBlock />, <EssentialInfoSection />]) { + const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>); + expect(html).toContain('짧게 요약한 숙소 소개'); + expect(html).not.toContain('숙소 소개 전체 원문입니다.'); + } + const about = renderToStaticMarkup(<SiteProvider payload={payload}><AboutSection /></SiteProvider>); + expect(about).toContain('숙소 소개 전체 원문입니다.'); +}); + +it.each([undefined, ' '])('keeps original text when summary is %s', (summary) => { + const payload = fixture(summary); + const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>); + expect(html).toContain('숙소 소개 전체 원문입니다.'); +}); + +it('does not expose summaries from unverified facts', () => { + const payload = fixture('검증 안 된 요약'); + payload.facts[0].status = FactStatus.UNVERIFIED; + for (const section of [<AnswerBlock />, <EssentialInfoSection />]) { + const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>); + expect(html).not.toContain('검증 안 된 요약'); + } +}); diff --git a/solution/site/src/sections/items/PostcardMakerSection.tsx b/solution/site/src/sections/items/PostcardMakerSection.tsx new file mode 100644 index 0000000..5f980b2 --- /dev/null +++ b/solution/site/src/sections/items/PostcardMakerSection.tsx @@ -0,0 +1,194 @@ +/** + * 엽서 쓰기 — 손님이 사진을 고르고 한 마디를 적어 그 자리에서 엽서를 만들어 공유한다. + * + * ★ 시연본(`scripts/mockup/inject.js` postcardMaker)에서 옮겨 왔다 (2026-09-14 대표: + * "웹빌드 해도 엽서쓰기 나오게"). 시연본은 주입 스크립트라 발행본에는 없었다. + * ★ 그림 규칙은 `@site/lib/postcard-canvas` 한 곳에 있다 — 여기서는 고르기·입력·공유만 한다. + * ★ **파일 그대로 공유**다(대표 확정). Web Share API 가 파일을 받으면 이미지가 그대로 대화방에 + * 올라간다. 못 받는 브라우저(주로 데스크톱)는 링크만 보내고, 그것도 안 되면 저장으로 떨어진다. + * ★ 버튼 문구에 앱 이름을 못 박지 않는다 — 공유 시트는 OS 가 채운다. 손끝으로 쓰는 기기에서만 + * "카카오톡 등으로 공유" 라고 적는다(맥에서는 메시지·AirDrop 정도만 떠서 거짓말이 된다). + */ +import {useEffect, useRef, useState} from 'react'; +import {useSite} from '@site/lib/site-context'; +import {Carousel, Section} from '@site/lib/ui'; +import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas'; + +const FILE_NAME = 'postcard.png'; + +export function PostcardMakerSection() { + const payload = useSite(); + const photos = (payload.media ?? []).filter((m) => m.url); + const canvasRef = useRef<HTMLCanvasElement>(null); + const [picked, setPicked] = useState(0); + const [text, setText] = useState(''); + const [hint, setHint] = useState(''); + const [touch, setTouch] = useState(false); + /* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석). + 굽는 쪽이 사진을 우리 오리진으로 옮기고부터(prerender `mirrorMedia`) 여기 걸리는 건 + **내려받기에 실패한 사진**뿐이다. 그때는 미리보기만 남기고 저장·공유를 막는다 — + 눌러도 안 되는 단추를 두지 않는다. */ + const [exportable, setExportable] = useState(true); + + useEffect(() => { + setTouch(window.matchMedia?.('(pointer: coarse)').matches ?? false); + }, []); + + const placeName = payload.place.name; + const region = String(payload.place.address ?? '').split(' ').slice(0, 2).join(' '); + + useEffect(() => { + const canvas = canvasRef.current; + const photo = photos[picked]; + if (!canvas || !photo?.url) return; + // 글자를 칠 때마다 다시 그린다. 80ms 는 시연본과 같은 값 — 한 글자마다 그리면 캔버스가 버벅인다. + const timer = window.setTimeout(() => { + void drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable); + }, 80); + return () => window.clearTimeout(timer); + }, [picked, text, placeName, region, photos]); + + // 폰트가 늦게 실리면 글씨가 기본 서체로 그려진 채 남는다 — 실리고 나서 한 번 더 그린다. + useEffect(() => { + const canvas = canvasRef.current; + const photo = photos[picked]; + if (!canvas || !photo?.url || !document.fonts?.ready) return; + void document.fonts.ready.then(() => drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable)); + // 첫 그림 한 번만 — 이후는 위 효과가 맡는다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (photos.length === 0) return null; + + /** 몇 줄로 접힐지는 폭·서체에 달려 글자 수로는 못 잰다 — 그릴 때와 같은 함수로 재서 막는다. */ + function withinLineLimit(next: string): boolean { + const ctx = canvasRef.current?.getContext('2d'); + if (!ctx) return true; + ctx.font = `600 46px serif`; + return wrapLines(ctx, `“${next}”`, textColumnWidth()).length <= MAX_LINES; + } + + function exportBlob(): Promise<Blob | null> { + return new Promise((resolve) => { + const canvas = canvasRef.current; + if (!canvas) return resolve(null); + canvas.toBlob((blob) => resolve(blob), 'image/png', 0.95); + }); + } + + function download(blob: Blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = FILE_NAME; + a.click(); + URL.revokeObjectURL(url); + } + + async function share() { + const blob = await exportBlob(); + if (!blob) return; + const pageUrl = document.querySelector<HTMLMetaElement>('meta[property="og:url"]')?.content + ?? `${location.origin}${location.pathname}`; + const file = new File([blob], FILE_NAME, {type: 'image/png'}); + // 손님이 시트를 닫은 것도 거절로 온다 — 오류로 다루지 않는다. + if (navigator.canShare?.({files: [file]})) { + await navigator.share({files: [file], title: `${placeName} 엽서`, text: pageUrl}).catch(() => {}); + return; + } + if (navigator.share) { + await navigator.share({title: `${placeName} 엽서`, url: pageUrl}).catch(() => {}); + setHint('이 브라우저는 사진을 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.'); + return; + } + download(blob); + setHint('이 브라우저는 공유 시트를 못 열어 저장으로 대신했습니다.'); + } + + return ( + <Section + id="postcard-maker" + title="엽서 쓰기" + lead={`${placeName}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`} + > + {/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을 + 그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이 + 이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다. + ★ 폭은 두 번 왔다 갔다 했다(2026-09-15): 320 → 240(너무 작다) → **360**. + 기준은 "한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" 다. */} + <div className="mx-auto grid max-w-3xl gap-5 sm:grid-cols-[minmax(0,360px)_minmax(0,1fr)] sm:items-start"> + <canvas + ref={canvasRef} + width={POSTCARD_SIZE} + height={POSTCARD_SIZE} + aria-label="내가 만든 엽서 미리보기" + /* ★ 비율을 CSS 로 못 박는다 (2026-09-15 대표: "모바일에서 깨지잖아"). + `<canvas>` 에 width 만 주면 브라우저에 따라 **높이가 속성값(1080px) 그대로** 남아 + 1080 폭짜리 그림이 좁은 폭에 눌린 채 늘어난다. `aspect-square h-auto` 로 못 박으면 + 어느 브라우저에서도 정사각이다. `block` 은 inline 기준선 아래 생기는 틈을 없앤다. */ + className="border-line mx-auto block aspect-square h-auto w-full max-w-[360px] border" + /> + {/* ★ `min-w-0` 이 없으면 모바일에서 칸이 통째로 밀려난다 (2026-09-15 대표: "모바일에서 + 엽서 넘침"). 그리드 칸의 기본 `min-width:auto` 는 **안에 든 것의 최소 너비**다 — + 사진 레일은 썸네일을 옆으로 죽 늘어놓으므로 그 값이 2,000px 을 넘었고(실측 390px + 화면에서 칸이 2056px), 칸이 화면 밖으로 나갔다. 스크롤 상자를 품은 칸에는 늘 붙인다. */} + <div className="flex min-w-0 flex-col gap-2.5"> + {/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에 + slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만 + 손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석) + — 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */} + <div className="min-w-0" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}> + <Carousel label="엽서 사진" gap={0.5} dots={false} autoplay={false}> + {photos.map((photo, i) => ( + <button + key={photo.url} + type="button" + onClick={() => setPicked(i)} + role="radio" + aria-checked={i === picked} + aria-label={`사진 ${i + 1}`} + className="border-line size-10 shrink-0 overflow-hidden rounded border transition-opacity" + style={{opacity: i === picked ? 1 : 0.5}} + > + <img src={photo.url} alt="" loading="lazy" className="size-full object-cover" /> + </button> + ))} + </Carousel> + </div> + + <textarea + rows={2} + maxLength={60} + value={text} + placeholder="엽서에 쓸 한 마디를 적어 보세요" + onChange={(e) => { if (withinLineLimit(e.target.value)) setText(e.target.value); }} + className="border-line w-full rounded border p-2.5 text-[length:var(--fs-sm)]" + /> + <p className="text-muted -mt-1 text-[length:var(--fs-xs)]">{text.length}/60</p> + + {exportable ? ( + <div className="flex flex-wrap gap-2"> + <button type="button" onClick={() => void share()} className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)] font-bold"> + {touch ? '카카오톡 등으로 공유' : '공유하기'} + </button> + <button + type="button" + onClick={() => void exportBlob().then((b) => b && download(b))} + className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)]" + > + 이미지로 저장 + </button> + </div> + ) : ( + /* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 와야 풀리고, + 그 일은 굽는 쪽(prerender `mirrorMedia`)이 한다. 여기까지 왔다는 건 그게 실패했다는 뜻. */ + <p className="text-muted text-[length:var(--fs-sm)]"> + 이 사진은 다른 사이트에 올라와 있어 저장·공유가 막혀 있습니다. 화면으로만 보여 드립니다. + </p> + )} + {hint && <p className="text-muted text-[length:var(--fs-xs)]">{hint}</p>} + </div> + </div> + </Section> + ); +} diff --git a/solution/site/src/sections/items/ReadingSection.tsx b/solution/site/src/sections/items/ReadingSection.tsx new file mode 100644 index 0000000..d94236c --- /dev/null +++ b/solution/site/src/sections/items/ReadingSection.tsx @@ -0,0 +1,100 @@ +/** + * 지역 읽기 — 도시 이야기 더미에서 **매번 몇 개만** 뽑아 한 꼭지씩 넘겨 본다. + * + * ★ 왜 다 안 보여주나 (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?" → + * "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로") + * 서른 꼭지를 한 화면에 펴면 스크롤 압박이 된다. 접는 게 아니라 **돌려 보여준다** — + * 다음에 들어오면 다른 조합이 뜬다. 그래서 `<details>` 를 쓰지 않는다(다 남겨야 하는 + * FAQ·일력과는 다른 경우다). + * ★ 인용은 손해 보지 않는다 — 안 뽑힌 꼭지도 `window.__SITE_PAYLOAD__` 로 문서 안에 그대로 + * 남는다(`scripts/prerender.ts`). 화면에서 고르는 것과 문서에서 지우는 것은 다른 일이다. + * ★ 뽑기는 **붙은 뒤에** 한다. 구운 HTML 과 첫 렌더가 달라지면 하이드레이션이 깨진다 — + * 서버는 앞에서부터 여섯 개를 세우고(크롤러가 읽는 것이 이것이다), 브라우저가 붙는 순간 + * 한 번 섞는다. 다시 섞지는 않는다(재렌더마다 바뀌면 읽던 카드가 눈앞에서 사라진다). + * ★ 갈래(문학·역사·…)로 묶어 세우지 않는다. 갈래는 카드 위 이름표로만 쓰고, 뽑기는 + * 갈래를 가리지 않는다 — 갈래마다 3~4개씩 뽑던 판은 한 번에 15~19개가 떴다. + */ +import {useEffect, useMemo, useState} from 'react'; +import type {ReadingItem} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {sectionItems, sectionName} from '@site/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine} from './common'; + +/** 한 번에 세우는 꼭지 수. 5 와 6 사이에서 흔들어 "볼 때마다 다르다"를 만든다. */ +const MIN_PICK = 5; +const MAX_PICK = 6; + +function shuffled<T>(list: readonly T[]): T[] { + const copy = list.slice(); + for (let i = copy.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +export function ReadingSection() { + const payload = useSite(); + // 파서가 매번 새 배열을 만들면 effect가 다시 추첨하며 무한 렌더링한다. + const parsed = useMemo(() => sectionItems<ReadingItem>(payload, 'reading'), [payload]); + const all = parsed.items; + // 서버·첫 렌더가 같아야 한다. 섞기는 아래 useEffect 가 붙은 뒤에 한 번만. + const [picked, setPicked] = useState<ReadingItem[]>(() => all.slice(0, MAX_PICK)); + + useEffect(() => { + if (all.length <= MIN_PICK) { + setPicked(all); + return; + } + const count = Math.random() < 0.5 ? MIN_PICK : MAX_PICK; + setPicked(shuffled(all).slice(0, count)); + // 더미 전체가 바뀔 때만 다시 뽑는다(발행본에서는 한 번뿐이다). + }, [all]); + + if (all.length === 0) return null; + + const region = payload.place.addressLocality ?? '지역'; + return ( + <ItemSection + id="reading" + name={parsed.title || sectionName(payload, 'reading', `${region} 읽기`)} + subtitle={parsed.subtitle} + > + <Rail label={`${region} 읽기`}> + {picked.map((item, index) => ( + <article + key={`${item.title}-${index}`} + className="w-[86%] shrink-0 grow-0 sm:w-[min(600px,56%)]" + > + <div + className="h-full border p-5" + style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} + > + {/* 갈래 이름표. '문학면' 처럼 신문 지면으로 부르지 않는다 — 그 틀이 없다. */} + {item.group && ( + <p + className="text-[11px] font-bold tracking-[0.18em]" + style={{color: ITEM_ACCENT}} + > + {item.group} + {item.year != null && <span className="font-normal opacity-70"> · {item.year}</span>} + </p> + )} + <h3 + className="serif mt-2 border-b-2 pb-2.5 text-xl leading-snug font-bold" + style={{borderColor: 'currentColor'}} + > + {item.title} + </h3> + {/* 서너 문장이 들어온다 — 줄바꿈이 낱말 가운데를 끊지 않게 keep-all 로 둔다. */} + <p className="mt-3 text-[14.5px] leading-[1.8] break-keep opacity-90">{item.body}</p> + <div className="mt-4 border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}> + <SourceLine source={item.source} verified={item.verified} /> + </div> + </div> + </article> + ))} + </Rail> + </ItemSection> + ); +} diff --git a/solution/site/src/sections/items/SongsSection.tsx b/solution/site/src/sections/items/SongsSection.tsx index ee6edfd..281d49b 100644 --- a/solution/site/src/sections/items/SongsSection.tsx +++ b/solution/site/src/sections/items/SongsSection.tsx @@ -24,8 +24,39 @@ import { tally, } from './common'; -/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */ -const FALLBACK_LABEL = ITEM_ACCENT; +/** + * 곡이 라벨 색을 안 줬을 때 쓸 색. + * + * ★ 왜 팔레트인가 (2026-09-15 대표 지적: "왜 색깔 다 빨간색이야") + * 예전 폴백은 템플릿 강조색 하나였다(`ITEM_ACCENT`). 그런데 **실측 67곡 중 63곡에 + * `labelColor` 가 없다** — 프롬프트가 그 칸을 시키는데도 모델이 공통 규칙 2 + * ("확인되지 않은 값은 필드를 통째로 뺀다")를 색에까지 적용해 버린다. 결과는 + * 판 스물다섯 장이 전부 같은 주朱색인 진열대였다. 색은 사실이 아니라 디자인 값이라 + * **비어 있어도 화면은 달라야 한다.** + * ★ **색은 시연본(`/s/stay`)이 쓰는 그 23색 그대로다** (2026-09-15 대표: "그냥 정해놔 · + * /s/stay 보고서 맞춰라"). 시연본은 25곡에 색이 다 붙어 있어 판 진열대가 알록달록한데, + * 제품은 그 값이 비어 한 색으로 깔렸다. 목록을 새로 짜지 않고 **그 팔레트를 옮겨 왔다** — + * 두 벌이 되면 시연본과 제품의 판 색이 갈린다. 원본은 + * `site/scripts/mockup/stay-payload-new.json` 의 `songs[].labelColor` 다. + * ★ 제목으로 고른다 — **무작위가 아니다.** 서버에서 구운 색과 브라우저가 고른 색이 + * 달라지면 하이드레이션이 어긋나고, 새로고침마다 판 색이 바뀌면 그것도 우스워진다. + */ +const LABEL_PALETTE = [ + '#d4551f', '#1d3c74', '#2a6053', '#7a3b52', '#a34a2a', + '#334b6d', '#4f6b3a', '#6b2f3a', '#2b6b7a', '#8a4b2a', + '#4a3f6b', '#3a5a4a', '#b5603a', '#356b8a', '#6b4a2a', + '#2f5b6b', '#a3562a', '#4a4a6b', '#c8873a', '#2f6b4f', + '#7a4a6b', '#5a5a4a', '#3f5f6b', +]; + +/** 제목 → 팔레트 index. 짧고 안정적이면 된다(암호학적 용도가 아니다). */ +function labelColorOf(song: SongItem): string { + if (song.labelColor) return song.labelColor; + const key = song.title ?? ''; + let hash = 0; + for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0; + return LABEL_PALETTE[hash % LABEL_PALETTE.length]; +} export function SongsSection() { const payload = useSite(); @@ -72,7 +103,7 @@ export function SongsSection() { <div className="w4-disc w4-spin absolute inset-2 rounded-full" style={{ - ['--lbl' as string]: current.labelColor || FALLBACK_LABEL, + ['--lbl' as string]: labelColorOf(current), ['--w4-vinyl' as string]: ITEM_INVERSE, }} > @@ -159,7 +190,7 @@ export function SongsSection() { <span className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105" style={{ - ['--lbl' as string]: song.labelColor || FALLBACK_LABEL, + ['--lbl' as string]: labelColorOf(song), ['--w4-vinyl' as string]: ITEM_INVERSE, boxShadow: index === playing ? `0 0 0 2px ${ITEM_ACCENT}` : undefined, diff --git a/solution/site/src/sections/items/StorySection.tsx b/solution/site/src/sections/items/StorySection.tsx index 4f51e5a..4113f84 100644 --- a/solution/site/src/sections/items/StorySection.tsx +++ b/solution/site/src/sections/items/StorySection.tsx @@ -1,8 +1,8 @@ /** - * 군산 이야기 — 레트로 아이템 다섯을 한 섹션에 묶고 탭으로 가른다. + * 지역 이야기 — 레트로 아이템 다섯을 한 섹션에 묶고 탭으로 가른다. * * ★ 왜 묶나 (2026-09-04, 사장님 요청) - * 가요 다방·오늘의 한 장·인물 열전·시간의 골목·오늘의 엽서가 각각 섹션으로 서 있었다. + * 가요 다방·인물 열전·시간의 골목·지역 읽기·오늘의 엽서가 각각 섹션으로 서 있었다. * 실측 390px 에서 다섯이 합쳐 3,470px — 전체의 17% 이고, 예약하러 온 손님은 이 구간을 * 다섯 화면 넘게 밀어야 아래(주변·FAQ)에 닿았다. 성격이 같은 것들이니 한 자리에 묶고 * **한 번에 하나만** 편다. @@ -15,9 +15,9 @@ import {useState} from 'react'; import {useSite} from '@site/lib/site-context'; import {sectionItems, sectionName} from '@site/lib/derive'; import {SongsSection} from './SongsSection'; -import {DailySection} from './DailySection'; import {PeopleSection} from './PeopleSection'; import {ChronicleSection} from './ChronicleSection'; +import {ReadingSection} from './ReadingSection'; import {PostcardSection} from './PostcardSection'; import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common'; @@ -26,20 +26,28 @@ import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common'; * 탭은 데이터가 있는 것만 선다 — 사장님이 아이템 하나를 끄면 문장이 곧바로 거짓말이 된다. * 실제로 '가요 다방'과 '오늘의 한 장'을 뺐더니 셋인데 "다섯 갈래"라고 적혀 있었다. */ -const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯'}; +const COUNT_WORD: Record<number, string> = { + 1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', 7: '일곱', +}; -const TABS = [ - {id: 'songs', label: '가요 다방', Component: SongsSection}, - {id: 'daily', label: '오늘의 한 장', Component: DailySection}, - {id: 'people', label: '인물 열전', Component: PeopleSection}, - {id: 'chronicle', label: '시간의 골목', Component: ChronicleSection}, - {id: 'postcard', label: '오늘의 엽서', Component: PostcardSection}, -] as const; +/* ★ '읽기' 만 라벨에 지명이 든다 — 나머지 넷은 어느 도시에서도 같은 이름이지만 + 이건 "그 도시를 읽는다" 가 이름의 뜻이라 지명을 빼면 무엇을 읽는지가 사라진다. + 지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */ +const tabsOf = (region: string) => + [ + {id: 'songs', label: '가요 다방', Component: SongsSection}, + {id: 'people', label: '인물 열전', Component: PeopleSection}, + {id: 'chronicle', label: '시간의 골목', Component: ChronicleSection}, + {id: 'reading', label: `${region} 읽기`, Component: ReadingSection}, + {id: 'postcard', label: '오늘의 엽서', Component: PostcardSection}, + ] as const; export function StorySection() { const payload = useSite(); + const region = payload.place.addressLocality ?? '지역'; + const title = sectionName(payload, 'story', `${region} 이야기`); // 데이터가 있는 것만 탭이 된다 — 눌러서 빈 화면을 보게 하지 않는다. - const tabs = TABS.filter((tab) => sectionItems(payload, tab.id).items.length > 0); + const tabs = tabsOf(region).filter((tab) => sectionItems(payload, tab.id).items.length > 0); const [active, setActive] = useState(0); if (tabs.length === 0) return null; @@ -65,13 +73,13 @@ export function StorySection() { > <div className="shell"> <h2 id="story-heading" className="h2"> - {sectionName(payload, 'story', '군산 이야기')} + {title} </h2> <p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70"> 이 도시를 {COUNT_WORD[tabs.length] ?? `${tabs.length}`} 갈래로 봅니다. 하나씩 골라 보세요. </p> - <div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label="군산 이야기"> + <div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={title}> {tabs.map((tab, index) => { const on = index === active; return ( diff --git a/solution/site/src/sections/items/index.ts b/solution/site/src/sections/items/index.ts index ac428b0..ba2e670 100644 --- a/solution/site/src/sections/items/index.ts +++ b/solution/site/src/sections/items/index.ts @@ -8,6 +8,7 @@ import {SongsSection} from './SongsSection'; import {DailySection} from './DailySection'; import {PeopleSection} from './PeopleSection'; import {ChronicleSection} from './ChronicleSection'; +import {ReadingSection} from './ReadingSection'; import {PostcardSection} from './PostcardSection'; import {QuizSection} from './QuizSection'; import {ItinerarySection} from './ItinerarySection'; @@ -20,12 +21,13 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = { daily: DailySection, people: PeopleSection, chronicle: ChronicleSection, + reading: ReadingSection, postcard: PostcardSection, quiz: QuizSection, itinerary: ItinerarySection, video: VideoSection, // 인스타에 올라오는 그때그때의 행사. 사장님이 옮겨 적고 원문 주소를 단다. event: EventSection, - // 레트로 다섯(가요다방·오늘의한장·인물열전·시간의골목·엽서)을 탭으로 묶은 자리. + // 레트로 아이템(가요다방·인물열전·시간의골목·지역읽기·엽서)을 탭으로 묶은 자리. story: StorySection, }; diff --git a/solution/site/src/sections/items/reading.test.tsx b/solution/site/src/sections/items/reading.test.tsx new file mode 100644 index 0000000..0cf532e --- /dev/null +++ b/solution/site/src/sections/items/reading.test.tsx @@ -0,0 +1,62 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import type {ReadingItem} from '@o2o/shared'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {ReadingSection} from './ReadingSection'; +import {StorySection} from './StorySection'; + +function withReading(items: ReadingItem[]) { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.story = {...(payload.local.story ?? {}), reading: items}; + return payload; +} + +const ITEMS: ReadingItem[] = Array.from({length: 9}, (_, i) => ({ + group: '문학', + title: `꼭지 ${i}`, + body: `본문 ${i}`, + source: {name: '네이버에서 찾아보기', url: `https://search.naver.com/search.naver?query=${i}`}, +})); + +it('구운 HTML 은 앞에서부터 여섯 꼭지다 — 섞기는 붙은 뒤에 한다', () => { + // 서버와 첫 렌더가 달라지면 하이드레이션이 깨진다. 무작위는 useEffect 뒤에만 돈다. + const html = renderToStaticMarkup( + <SiteProvider payload={withReading(ITEMS)}> + <ReadingSection /> + </SiteProvider>, + ); + expect(html).toContain('꼭지 0'); + expect(html).toContain('꼭지 5'); + expect(html).not.toContain('꼭지 6'); +}); + +it('꼭지가 없으면 섹션 자체가 없다', () => { + const html = renderToStaticMarkup( + <SiteProvider payload={withReading([])}> + <ReadingSection /> + </SiteProvider>, + ); + expect(html).toBe(''); +}); + +it('출처 링크를 그대로 단다 — 검색 링크는 서버가 만든 값이다', () => { + const html = renderToStaticMarkup( + <SiteProvider payload={withReading(ITEMS.slice(0, 1))}> + <ReadingSection /> + </SiteProvider>, + ); + expect(html).toContain('search.naver.com'); + expect(html).toContain('네이버에서 찾아보기'); +}); + +it("'지역 이야기' 탭에 지명이 든 이름으로 선다 — 군산을 코드에 박지 않는다", () => { + const payload = withReading(ITEMS); + const region = payload.place.addressLocality; + const html = renderToStaticMarkup( + <SiteProvider payload={payload}> + <StorySection /> + </SiteProvider>, + ); + expect(html).toContain(`${region} 읽기`); +}); diff --git a/solution/site/src/sections/local-guide.test.tsx b/solution/site/src/sections/local-guide.test.tsx new file mode 100644 index 0000000..98dad55 --- /dev/null +++ b/solution/site/src/sections/local-guide.test.tsx @@ -0,0 +1,45 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {LocalGuideSection} from './LocalGuideSection'; + +it('renders collected descriptions and addresses with numeric distance filters', () => { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.attractions = []; + payload.local.restaurants = [ + {name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A', + description: 'Collected description', distanceMeters: 401, distanceText: '400m', imageUrl: 'https://example.com/a.jpg'}, + {name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100, imageUrl: 'https://example.com/b.jpg'}, + {name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C', imageUrl: 'https://example.com/c.jpg'}, + ]; + const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>); + expect(html).toContain('Collected description'); + expect(html).toContain('Address A'); + expect(html).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/); + expect(html).toContain('Cafe C'); +}); + +it('excludes restaurants without an image', () => { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.attractions = []; + payload.local.restaurants = [ + {name: 'Cafe With Image', category: 'cafe', searchQuery: 'Cafe With Image', imageUrl: 'https://example.com/a.jpg'}, + {name: 'Cafe Without Image', category: 'cafe', searchQuery: 'Cafe Without Image'}, + ]; + const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>); + expect(html).toContain('Cafe With Image'); + expect(html).not.toContain('Cafe Without Image'); +}); + +it('links restaurant and attraction cards to Naver Map search, not web search', () => { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.restaurants = [ + {name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', imageUrl: 'https://example.com/a.jpg'}, + ]; + payload.local.attractions = [{name: 'Spot A', category: 'spot', searchQuery: 'Spot A'}]; + const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>); + expect(html).toContain('https://map.naver.com/p/search/Cafe%20A'); + expect(html).toContain('https://map.naver.com/p/search/Spot%20A'); + expect(html).not.toContain('search.naver.com'); +}); diff --git a/solution/site/src/sections/stay-booking.test.tsx b/solution/site/src/sections/stay-booking.test.tsx index 0dd28e6..5284bc2 100644 --- a/solution/site/src/sections/stay-booking.test.tsx +++ b/solution/site/src/sections/stay-booking.test.tsx @@ -106,6 +106,15 @@ describe('stayBookingView — 예약에 쓸 값을 고르는 자리', () => { }); describe('발행 HTML — 예약 섹션이 실제로 나가는가', () => { + it('예약 전 확인은 이용안내 안에 한 번만 표시한다', () => { + const baked = html(); + expect(baked.match(/예약 전 확인/g)).toHaveLength(1); + expect(baked).not.toContain('id="summary"'); + const booking = baked.slice(baked.indexOf('id="booking"')); + expect(booking).not.toContain('예약 전 확인'); + expect(baked).toContain('취소·환불 규정'); + expect(baked).toContain('체크인 시간'); + }); it('요금 · 인원 · 취소 규정 · 예약 창구가 한자리에 있다', () => { const text = visibleText(html()); expect(text).toContain('예약 창구'); diff --git a/solution/site/src/sections/stay-guide.test.tsx b/solution/site/src/sections/stay-guide.test.tsx new file mode 100644 index 0000000..c89bba0 --- /dev/null +++ b/solution/site/src/sections/stay-guide.test.tsx @@ -0,0 +1,71 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {sanitizePayloadForPublish, FactStatus, SourceType} from '@o2o/shared'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {EssentialInfoSection} from './EssentialInfoSection'; + +function fixture(confirmed = true) { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.facts = []; + payload.links = [{ + channel: 1, title: 'NOL', url: 'https://nol.yanolja.com/stay/domestic/10068088', confirmed, + stayGuide: { + policy: '체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원', + service: '주방\n욕조', + reservation: '- 반려동물 입실금지\n<script>alert(1)</script>', + }, + }]; + return payload; +} + +it('renders guides without facts, before booking actions, preserving line breaks and escaping HTML', () => { + const payload = fixture(); + const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>); + expect(html).toContain('예약 전 확인'); + expect(html).toContain('체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원'); + expect(html).toContain('주방\n욕조'); + expect(html).toContain('반려동물 입실금지'); + expect(html).toContain('whitespace-pre-line'); + expect(html).toContain('<script>'); + expect(html).not.toContain('<script>'); + expect(html).not.toContain('모든 항목이 사업자 확인'); + expect(html.indexOf('반려동물 입실금지')).toBeLessThan(html.indexOf('예약은 아래로')); +}); + +it('does not render or embed guides from unconfirmed links', () => { + const payload = fixture(false); + const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>); + expect(html).toBe(''); + expect(JSON.stringify(sanitizePayloadForPublish(payload))).not.toContain('반려동물 입실금지'); +}); + +it('supports older payloads without guides', () => { + const payload = fixture(); + delete payload.links[0].stayGuide; + expect(renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>)).toBe(''); +}); + +it('keeps structured rows and restrictions without the original disclosure or source link', () => { + const payload = fixture(); + payload.facts = [{key: 'check_in_time', label: '체크인 시간', value: '16:00', + scope: 'place', type: 'time', status: FactStatus.CORRECTED, sourceType: SourceType.OWNER, + critical: true, required: true}]; + payload.links[0].stayGuide!.fields = [ + {key: 'check_in_time', label: '체크인 시간', value: '15:00', group: 'rules'}, + {key: 'extra_person_fee', label: '인원 추가 요금', value: '20,000원', note: '1인당 · 전 연령 동일', group: 'rules'}, + {key: 'cooking_allowed', label: '취사', value: '가능', note: '생선구이 조리금지', group: 'facilities'}, + ]; + const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>); + expect(html.match(/체크인 시간/g)).toHaveLength(1); + expect(html).toContain('16:00'); + expect(html).toContain('20,000원'); + expect(html).toContain('1인당 · 전 연령 동일'); + expect(html).toContain('생선구이 조리금지'); + expect(html).not.toContain('<details'); + expect(html).not.toContain('NOL 이용안내 · 시설 원문'); + expect(html).not.toContain('NOL 안내 원문'); + expect(html).not.toContain('체크인 15:00 체크아웃 11:00'); + expect(html).toContain('반려동물 입실금지'); + expect(html).toContain('예약은 아래로'); +}); diff --git a/solution/site/src/sections/weather.test.tsx b/solution/site/src/sections/weather.test.tsx new file mode 100644 index 0000000..01c42c9 --- /dev/null +++ b/solution/site/src/sections/weather.test.tsx @@ -0,0 +1,30 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {WeatherSection} from './WeatherSection'; +import type {WeatherSnapshot} from '@o2o/shared'; + +function render(weather?: WeatherSnapshot) { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.weather = weather; + return renderToStaticMarkup(<SiteProvider payload={payload}><WeatherSection /></SiteProvider>); +} + +it('uses the first sky and temperature lines for deterministic HTML', () => { + const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '구름많음', noteSets: {'구름많음': ['구름 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}}; + expect(render(weather)).toContain('구름 첫 줄'); + expect(render(weather)).toContain('더위 첫 줄'); + expect(render(weather)).not.toContain('다음 줄'); + expect(render(weather)).toBe(render(weather)); +}); + +it('preserves legacy single notes', () => { + const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}}); + expect(html).toContain('기존 비 안내'); + expect(html).toContain('겉옷 안내'); +}); + +it('does not invent weather when an observation is missing', () => { + expect(render()).toBe(''); +}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 67c8948..72ab4db 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -56,6 +56,23 @@ export function readBakedLastmod(html: string): string | undefined { return value && !Number.isNaN(Date.parse(value)) ? value : undefined; } +/** + * 구운 `index.html` 의 canonical 에서 **오리진**을 꺼낸다. 없으면 빈 문자열. + * + * ★ 왜 필요한가 — 오리진은 payload 에서 온다. 그런데 기동 때 도는 자산 주소 갱신 + * (`prerender --refresh-assets`)은 payload 를 하나도 읽지 않아서 오리진을 모른다. + * 그 경로에서 루트 색인 파일(sitemap.xml · llms.txt · `/s` 목록)을 다시 쓰지 못하는 동안 + * **내려간 사이트가 사이트맵에 그대로 남았다** — 페이지는 404 인데 구글은 계속 긁고, + * 목록에는 열리지 않는 카드가 남는다(실측 2026-09-15). + * ★ 환경변수로 받지 않는다. 이미 나가 있는 페이지가 **자기 주소로 선언한 값**이 유일한 + * 정답이다 — 호스트 설정이 바뀐 뒤라면 새 값으로 색인을 다시 쓰는 편이 오히려 틀린다 + * (그 페이지들의 canonical 은 여전히 옛 주소다). + */ +export function readBakedOrigin(html: string): string { + const match = /<link rel="canonical" href="(https?:\/\/[^/"]+)/.exec(html); + return match ? match[1] : ''; +} + /** * `/s/` 발행 사이트 목록 페이지. * diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts index ef0afa0..d4cb4aa 100644 --- a/solution/site/src/seo/head.ts +++ b/solution/site/src/seo/head.ts @@ -1,4 +1,4 @@ -import {deriveSurfaces, type SitePayload} from '@o2o/shared'; +import {deriveSurfaces, SiteStatus, type SitePayload} from '@o2o/shared'; import {collectJsonLd} from './jsonld'; import type {PageMeta} from './meta'; @@ -145,12 +145,45 @@ 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}), - // 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다. + /* + * ★ **발행된 사이트만** 색인된다. + * + * 예전엔 `index, follow` 를 박아 두고 "색인을 막을 이유가 없다"고 적어 뒀는데, 그건 + * 굽는 것이 곧 발행이던 시절의 말이다. 지금은 사장님이 빌더에서 미리보기를 누르면 + * draft 상태로도 구워진다 — 그 결과가 그대로 검색에 나갔다. + * + * 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고 + * 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가, + * 짓다 만 상태로 구글에 실려 있었다는 뜻이다. + * + * ★ 여기만 고치면 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — + * 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex). + * 두 자리에 규칙을 두지 않기 위해 판정을 이 한 곳에 둔다. + */ tag('meta', { name: 'robots', - content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1', + content: + site.status === SiteStatus.PUBLISHED + ? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' + : // follow 는 남긴다 — 색인은 막되 링크는 타게 둔다(자산·하위 경로 발견용). + 'noindex, follow', }), + /* + * ★ 파비콘. 발행본에는 아예 없어서 브라우저 탭에 기본 아이콘이 떴다(실측 2026-09-15). + * 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않는다 — `basePath` 가 아니라 + * 루트 절대경로로 가리킨다(robots·sitemap 과 같은 자리다). + */ + tag('link', {rel: 'icon', type: 'image/png', sizes: '32x32', href: '/brand/favicon-w4a.png'}), + tag('link', {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg'}), + tag('link', {rel: 'apple-touch-icon', href: '/apple-touch-icon.png'}), tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}), /* * ★ 노치·상태바를 템플릿 색으로 채운다 (2026-09-09, 사장님: "모바일에서 노치가 투명으로 되던데") diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index 483430b..c24f1ff 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -6,8 +6,8 @@ import { factValue, joinUrl, sanitizeUnits, + selectAnsweredFaqs, selectPublishable, - selectPublishableFaqs, type ChannelLink, type FactEntry, type SitePayload, @@ -366,13 +366,23 @@ function priceRange(payload: SitePayload): string | undefined { return min === max ? `${fmt(min)}원` : `${fmt(min)}원 ~ ${fmt(max)}원`; } +/** FAQ 가 화면에 그려지는가 — 섹션이 켜져 있을 때만이다(HomePage 가 enabled 로 고른다). */ +function isFaqOnScreen(payload: SitePayload): boolean { + return payload.theme.sections.some((section) => section.id === 'faq' && section.enabled); +} + /** * FAQPage — AEO 에서 가장 크게 먹히는 마크업. * "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡히는 자리다. * 확인된 FAQ 가 하나도 없으면 아예 내보내지 않는다(빈 FAQPage 는 감점). + * ★ 문의 안내(TEMPLATE)는 싣지 않는다 — 답이 없는 문답이다(shared selectAnsweredFaqs). */ export function faqJsonLd(payload: SitePayload): Json | null { - const faqs = selectPublishableFaqs(payload.faqs); + // ★ 화면에 FAQ 가 없으면 내보내지 않는다 (2026-09-14). 섹션이 꺼져 있으면 HomePage 가 FaqSection 을 + // 아예 그리지 않는데, 여기서만 실으면 **구조화 데이터가 화면 값과 다르다**로 발행이 통째로 막힌다. + // 실측: FAQ 를 20개까지 채우자(COPY) 섹션을 꺼 둔 사이트에서 12건 불일치로 발행 실패. + if (!isFaqOnScreen(payload)) return null; + const faqs = selectAnsweredFaqs(payload.faqs); if (faqs.length === 0) return null; return { diff --git a/solution/site/src/seo/llms.ts b/solution/site/src/seo/llms.ts index a3aebb9..6825db9 100644 --- a/solution/site/src/seo/llms.ts +++ b/solution/site/src/seo/llms.ts @@ -3,8 +3,8 @@ import { factText, joinUrl, sanitizeUnits, + selectAnsweredFaqs, selectPublishable, - selectPublishableFaqs, type SitePayload, } from '@o2o/shared'; import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld'; @@ -88,7 +88,10 @@ export function renderLlmsTxt(payload: SitePayload): string { } // ── FAQ — LLM 이 가장 잘 인용하는 부분 ───────────────── - const faqs = selectPublishableFaqs(payload.faqs); + // ★ 문의 안내(TEMPLATE)는 뺀다. 규칙 3("모르는 것은 정보 없음") 과 달리 질문 자체가 우리가 붙인 것이다. + // 화면에 없는 것을 llms.txt 에만 적지 않는다 — 같은 이유로 JSON-LD 도 섹션이 켜졌을 때만 낸다. + const faqOnScreen = payload.theme.sections.some((section) => section.id === 'faq' && section.enabled); + const faqs = faqOnScreen ? selectAnsweredFaqs(payload.faqs) : []; if (faqs.length > 0) { lines.push('## 자주 묻는 질문'); lines.push(''); 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), }; } diff --git a/solution/site/vite.config.ts b/solution/site/vite.config.ts index e482341..ad0dbdd 100644 --- a/solution/site/vite.config.ts +++ b/solution/site/vite.config.ts @@ -34,6 +34,14 @@ export default defineConfig(({isSsrBuild}) => ({ ? undefined // --ssr 로 넘긴 엔트리를 그대로 쓴다. : {input: path.resolve(__dirname, 'index.html')}, }, + // ★ SSR 번들(dist/prerender/prerender.js)에 react·react-dom 등 node_modules 의존성까지 + // 전부 접어 넣는다. 워커 이미지가 "Node 런타임 + 컴파일된 렌더러"만 담게 하려는 것이다 + // (컨테이너 안에서 npm install 을 돌리지 않는다). noExternal 없이는 이 번들이 실행 시점에 + // node_modules 를 다시 찾는다 — 실측: node_modules 를 지우고 돌리면 + // `Cannot find package 'clsx'` 로 즉시 죽는다. + ssr: { + noExternal: true, + }, server: { watch: {usePolling: true, interval: 300}, },