merge: feature/supporters-autobuild 진행분 (주변 안내 /stay, 워커 publish 색인 단계) 반영

머지 뒤에 브랜치가 5개 커밋 앞서 있었다. 주변 안내(/stay·/en/stay) 복원과
워커 publish 단계(IndexNow·검색 콘솔 소유 확인)가 거기 있었고, 먼저 올린 머지가
이 둘을 떨어뜨렸다. supporters/ 정본은 i18n 방식을 유지하고, 사이트맵과 site.json 은
양쪽 추가를 모두 살렸다. 템플릿은 supporters/ 에서 다시 내보낸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-12 15:19:41 +09:00
commit bb68bdacad
15 changed files with 2358 additions and 34 deletions

View File

@ -1,6 +1,6 @@
# 다음 세션 핸드오버 · 2026-09-12 · 서포터즈 사이트 빌드 절차 (회복 일정 플래너·오시는 길 지도 통합 이후) # 다음 세션 핸드오버 · 2026-09-12 · 서포터즈 사이트 빌드 절차 (회복 일정 플래너·오시는 길 지도 통합 이후)
> 이 문서는 하위 문서다. INFINITH AI Discovery Product 전체(제품 여정·브랜치 통합·리드→큐→빌드 자동화·측정·가격)의 핸드오버는 메인 체크아웃 `docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md` 를 먼저 읽는다. > 이 문서는 하위 문서다. INFINITH AI Discovery Product 전체(제품 여정·브랜치 통합·리드→큐→빌드 자동화·측정·가격, §8 인사이트)의 핸드오버는 메인 체크아웃 `docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md` 를 먼저 읽는다. 다음 세션은 2026-09-14(월) Product 개발이다. 09-11 저녁 최종 상태: 세 사이트 모두 주변 안내(/stay)와 회복 일정(/plan)이 병존하고, 워커에 publish 단계가 있다.
작성 2026-09-11 · 작성자 Claude (haewon 세션) · 이전 핸드오버 `docs/NEXT_SESSION_2026-09-11_ORACLE.md`(오라클 첫 빌드), `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(자동 빌드 전체). 작성 2026-09-11 · 작성자 Claude (haewon 세션) · 이전 핸드오버 `docs/NEXT_SESSION_2026-09-11_ORACLE.md`(오라클 첫 빌드), `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(자동 빌드 전체).
이 문서는 2026-09-10~11 이틀간 붙인 기능(회복 일정 플래너 `/plan`·`/en/plan`, 오시는 길 지도, 전역 언어 전환, 모바일 헤더)과 그것을 워커·템플릿에 통합한 상태, 그리고 다음 병원을 Product로 빌드하는 절차를 다룬다. 이 문서는 2026-09-10~11 이틀간 붙인 기능(회복 일정 플래너 `/plan`·`/en/plan`, 오시는 길 지도, 전역 언어 전환, 모바일 헤더)과 그것을 워커·템플릿에 통합한 상태, 그리고 다음 병원을 Product로 빌드하는 절차를 다룬다.
@ -27,7 +27,7 @@ INFINITH 저장소. 자동 빌드(워커·템플릿)는 워크트리 ~/orca/work
| 병원별 큐레이션 장소 브리프 | `supporters/briefs/{viewclinic,oracle,wonjin}/planner.places.json` | 뷰 31·오라클 28·원진 31곳. 전부 verify(출시 전 확인) | | 병원별 큐레이션 장소 브리프 | `supporters/briefs/{viewclinic,oracle,wonjin}/planner.places.json` | 뷰 31·오라클 28·원진 31곳. 전부 verify(출시 전 확인) |
| 오시는 길 지도·길찾기 (Leaflet+OSM, 주소 복사, 네이버·카카오 길찾기, 영어는 구글 추가) | 템플릿 `src/components/VisitMap.astro`, 방문 안내 §3, 뷰 영어 홈 Getting Here | 좌표 `factSheet.geo`. 워커 tourism 단계가 관광공사 수집 기준 좌표(네이버 지역검색)를 geo 에 기록 | | 오시는 길 지도·길찾기 (Leaflet+OSM, 주소 복사, 네이버·카카오 길찾기, 영어는 구글 추가) | 템플릿 `src/components/VisitMap.astro`, 방문 안내 §3, 뷰 영어 홈 Getting Here | 좌표 `factSheet.geo`. 워커 tourism 단계가 관광공사 수집 기준 좌표(네이버 지역검색)를 geo 에 기록 |
| 전역 언어 전환·모바일 헤더 | 템플릿 Base.astro(langswitch, T.nav), `global.css` ≤900px 헤더 규칙 | 로고·메뉴 겹침 해결. 390px 하네스로 8페이지×3사이트 검증 | | 전역 언어 전환·모바일 헤더 | 템플릿 Base.astro(langswitch, T.nav), `global.css` ≤900px 헤더 규칙 | 로고·메뉴 겹침 해결. 390px 하네스로 8페이지×3사이트 검증 |
| 옛 주소 정리 | `vercel.json` redirects `/recovery`·`/stay`·`/en/recovery`·`/en/stay` → `/plan`·`/en/plan`. `recovery.astro`·`stay.astro` 는 템플릿에서 제거 | haewon 결정 2026-09-11: 메인 폴더의 `/plan` 이 정본 | | 주변 안내 복원 (2026-09-11 17시) | `/stay`·`/en/stay` (장소·축제·동선 지도·날씨·시술 후 관리 접이식). 내비 '주변 안내'·'Nearby', 언어 짝 /stay↔/en/stay. `/recovery`·`/en/recovery` → `/plan`·`/en/plan` 리다이렉트만 유지 | haewon 결정: 주변 안내와 회복 일정 플래너는 별개 페이지. 처음 통합 때 /stay 를 /plan 으로 덮어쓴 것을 되돌림. 스타일은 global.css '/* ── /en/stay ── */' 블록 |
| 원진 관광공사 수집·원문 수집 | `~/supporters-builds/wonjin/site/src/data/medicalTourism*.json`, `recoveryNotes.json` | 장소 123·축제 24, 원문 32문장 중 관리 문장 16건 붙임 | | 원진 관광공사 수집·원문 수집 | `~/supporters-builds/wonjin/site/src/data/medicalTourism*.json`, `recoveryNotes.json` | 장소 123·축제 24, 원문 32문장 중 관리 문장 16건 붙임 |
| Vercel 사고 재발 방지 | Productize `supporters/.vercel/project.json``.disabled-2026-09-11`, `supporters/DEPLOY_NOTE.md` | 두 폴더가 같은 프로젝트에 배포해 `/plan` 이 404 된 사고(09-11 10:20) 이후 | | Vercel 사고 재발 방지 | Productize `supporters/.vercel/project.json``.disabled-2026-09-11`, `supporters/DEPLOY_NOTE.md` | 두 폴더가 같은 프로젝트에 배포해 `/plan` 이 404 된 사고(09-11 10:20) 이후 |
@ -54,11 +54,13 @@ INFINITH 저장소. 자동 빌드(워커·템플릿)는 워크트리 ~/orca/work
``` ```
node workers/supporters-build/run.mjs --clinic <id> --url https://<병원 홈페이지> --name "<상호>" [--industry derm] [--deploy] node workers/supporters-build/run.mjs --clinic <id> --url https://<병원 홈페이지> --name "<상호>" [--industry derm] [--deploy]
``` ```
단계 순서: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → **planner** → inputs → briefs → generate → build → deploy. 부분 재실행은 `--from <단계>`. 생성 비용 약 $0.6/병원(글 6~8편). 단계 순서: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → **planner** → inputs → briefs → generate → build → deploy**publish**. 부분 재실행은 `--from <단계>`. 생성 비용 약 $0.6/병원(글 6~8편).
4. **확인**: `~/supporters-builds/<id>/status.json``report.tourism`(좌표·장소 수)·`report.planner`(붙은 원문 문장 수)·`report.recovery`. 빌드 게이트 오류 0 인지. 4. **확인**: `~/supporters-builds/<id>/status.json``report.tourism`(좌표·장소 수)·`report.planner`(붙은 원문 문장 수)·`report.recovery`. 빌드 게이트 오류 0 인지.
5. **사람 게이트**: 의료진 후보 확정, 이미지 육안 확인, 구조 기준선 승인(`node scripts/check.mjs --update-layout`, 첫 빌드는 LAYOUT_NO_BASELINE 경고), 규칙표 provisional 값 원장 확인. 5. **사람 게이트**: 의료진 후보 확정, 이미지 육안 확인, 구조 기준선 승인(`node scripts/check.mjs --update-layout`, 첫 빌드는 LAYOUT_NO_BASELINE 경고), 규칙표 provisional 값 원장 확인.
6. **모바일 검증**: 헤드리스 크롬은 창 최소 폭이 약 500px 이라 400px 직접 캡처가 부정확하다. 1280 창 안에 390px iframe 을 넣은 하네스로 찍는다(스크래치 `harness*.html` 방식, 홈·영상·방문·plan·뉴스룸·목록·글·병원 정보 8페이지). 6. **모바일 검증**: 헤드리스 크롬은 창 최소 폭이 약 500px 이라 400px 직접 캡처가 부정확하다. 1280 창 안에 390px iframe 을 넣은 하네스로 찍는다(스크래치 `harness*.html` 방식, 홈·영상·방문·plan·뉴스룸·목록·글·병원 정보 8페이지).
7. **배포**: `--deploy` 또는 병원 폴더에서 `vercel --prod --yes`. 메인 `supporters/`(뷰 샘플)는 별도로 `git push``vercel --prod`. 7. **배포**: `--deploy` 또는 병원 폴더에서 `vercel --prod --yes`. 메인 `supporters/`(뷰 샘플)는 별도로 `git push``vercel --prod`.
8. **발행(색인)**: 배포 뒤 워커 `publish` 단계가 자동 실행된다(`scripts/publish_site.mjs`). 샘플(noindex) 상태면 라이브 확인과 "색인 허용 전환 결정" 체크리스트만 낸다. 정식 공개는 `--indexable --deploy [--domain <도메인>]` 로 재실행: noindex 헤더 제거, 승인 글만 발행, IndexNow 알림, GSC 사이트맵 제출(서비스 계정 있을 때). 결과는 `status.report.publish`, 사람 몫은 `~/supporters-builds/<id>/publish-checklist.md``status.humanTasks`.
9. **완료 판정**: 체크리스트의 사람 몫(네이버 서치어드바이저 등록·소유 확인·사이트맵 제출, GSC 소유자 추가, 도메인, 법무 3건, 백링크 요청)이 끝나야 "완료"다. 끝나기 전 상태는 `published_pending_tasks`.
기존 병원 재빌드(플래너·지도 포함 최신 템플릿 적용): `--from data` 로 돌리면 글·영상 집계는 보존되고 나머지는 새로 만든다. 원진처럼 원문 수집을 워커 밖에서 돌린 병원도 `recovery` 단계가 같은 결과를 낸다(`recoveryNotes.cache.json` 은 병원 폴더에 남아 번역 재사용). 기존 병원 재빌드(플래너·지도 포함 최신 템플릿 적용): `--from data` 로 돌리면 글·영상 집계는 보존되고 나머지는 새로 만든다. 원진처럼 원문 수집을 워커 밖에서 돌린 병원도 `recovery` 단계가 같은 결과를 낸다(`recoveryNotes.cache.json` 은 병원 폴더에 남아 번역 재사용).

View File

@ -9,6 +9,4 @@ export default defineConfig({
trailingSlash: 'never', trailingSlash: 'never',
build: { format: 'file' }, build: { format: 'file' },
compressHTML: true, compressHTML: true,
// 주변 안내(/stay)는 회복 일정(/recovery)으로 합쳤다(haewon 2026-09-11). 정적 빌드라 meta refresh 페이지가 생긴다.
redirects: { '/stay': '/recovery', '/en/stay': '/en/recovery' },
}); });

View File

@ -0,0 +1,127 @@
// 배포 후 색인 작업 (워커 publish 단계). "서포터즈 사이트 완료"는 배포가 아니라 이 단계와 사람 체크리스트까지 끝난 상태다 (haewon 결정 2026-09-11).
// node scripts/publish_site.mjs --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]
//
// 자동으로 하는 것
// 1. 라이브 확인: 홈·robots.txt·sitemap.xml 응답, X-Robots-Tag(noindex) 여부, 사이트맵 URL 수
// 2. IndexNow(빙·네이버 공용): 색인 허용 빌드일 때만 사이트맵의 URL 전부를 api.indexnow.org 에 알린다. 키 파일은 워커 data 단계가 public/<key>.txt 로 넣는다
// 3. Google Search Console: GOOGLE_SERVICE_ACCOUNT_JSON(경로 또는 JSON) 이 있으면 사이트맵 제출 + 최대 20 URL 색인 상태 조회(URL Inspection API).
// 서비스 계정이 속성 소유자로 등록돼 있지 않으면 403 → 사람 체크리스트로 넘긴다
// 사람에게 남기는 것 (<work>/publish-checklist.md, status.humanTasks)
// 네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유 확인(첫 1회), 도메인 연결, 색인 허용 전환 전 법무·승인 확인
// 하지 않는 것: Google Indexing API(채용·라이브 방송 전용, 일반 페이지에 쓰면 가이드라인 위반), 색인 보장 약속.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createSign } from 'node:crypto';
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic'); const SITE = opt('site'); const URLBASE = (opt('url') || '').replace(/\/$/, '');
if (!clinic || !SITE || !URLBASE) { console.error('사용법: --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]'); process.exit(2); }
const WORK = resolve(opt('work', join(SITE, '..')));
const site = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'site.json'), 'utf8'));
const fact = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'factSheet.json'), 'utf8'));
const today = new Date().toISOString().slice(0, 10);
const report = { clinic, url: URLBASE, checkedAt: new Date().toISOString(), live: {}, indexable: false, indexnow: null, google: null, naver: null, humanTasks: [] };
const task = (id, title, why, how) => report.humanTasks.push({ id, title, why, how });
async function head(url) {
try { const r = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'infinith-supporters-publish/1.0' } }); const text = r.ok ? await r.text() : ''; return { ok: r.ok, status: r.status, robotsTag: r.headers.get('x-robots-tag') || '', text }; }
catch (e) { return { ok: false, status: 0, robotsTag: '', text: '', error: String(e.message ?? e) }; }
}
// ---------- 1. 라이브 확인 ----------
const home = await head(`${URLBASE}/`);
const robots = await head(`${URLBASE}/robots.txt`);
const sitemap = await head(`${URLBASE}/sitemap.xml`);
const urls = [...(sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g))].map((m) => m[1].trim());
const noindexHeader = /noindex/i.test(home.robotsTag);
const robotsBlocksAll = /Disallow:\s*\/\s*$/m.test(robots.text) && !/Allow:\s*\//.test(robots.text);
report.live = { home: home.status, robots: robots.status, sitemap: sitemap.status, sitemapUrls: urls.length, noindexHeader, robotsBlocksAll };
report.indexable = flag('indexable') || (home.ok && !noindexHeader && !robotsBlocksAll);
if (!home.ok) task('live', '라이브 응답 확인', `홈이 ${home.status} 로 응답했습니다.`, `${URLBASE}/ 를 열어 배포 상태를 확인`);
// ---------- 2. IndexNow (빙·네이버) ----------
if (report.indexable && urls.length) {
const key = site.indexNowKey;
if (!key) { report.indexnow = { skipped: 'site.json 에 indexNowKey 없음 (워커 data 단계가 만든다)' }; }
else {
const keyUrl = `${URLBASE}/${key}.txt`;
const kf = await head(keyUrl);
if (!kf.ok || kf.text.trim() !== key) { report.indexnow = { skipped: `키 파일 확인 실패 ${keyUrl} (${kf.status})` }; }
else {
try {
const host = new URL(URLBASE).host;
const r = await fetch('https://api.indexnow.org/indexnow', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls.slice(0, 10000) }) });
report.indexnow = { status: r.status, submitted: Math.min(urls.length, 10000), engines: 'Bing·Naver(IndexNow 참여)' };
} catch (e) { report.indexnow = { error: String(e.message ?? e) }; }
}
}
} else {
report.indexnow = { skipped: report.indexable ? '사이트맵 URL 없음' : '색인 허용 빌드가 아님(noindex). 샘플 단계에서는 알리지 않는다' };
}
// ---------- 3. Google Search Console ----------
async function googleToken(sa) {
const now = Math.floor(Date.now() / 1000);
const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const unsigned = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`;
const sig = createSign('RSA-SHA256').update(unsigned).sign(sa.private_key, 'base64url');
const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${unsigned}.${sig}` });
if (!r.ok) throw new Error(`토큰 실패 ${r.status}`);
return (await r.json()).access_token;
}
const saRaw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || '';
if (!report.indexable) {
report.google = { skipped: '색인 허용 빌드가 아님' };
} else if (!saRaw) {
report.google = { skipped: 'GOOGLE_SERVICE_ACCOUNT_JSON 없음' };
task('gsc-owner', 'Google Search Console 속성 등록·소유 확인 (첫 1회)', '서비스 계정이 없거나 속성 소유자로 등록되지 않아 사이트맵 제출과 색인 상태 조회를 자동으로 할 수 없습니다.', `1) Search Console 에 ${URLBASE} 속성 추가 2) 소유 확인: site.json googleSiteVerification 에 메타 태그 값(content)을 넣고 재빌드 3) 서비스 계정 이메일을 속성 소유자로 추가 4) Productize .env 에 GOOGLE_SERVICE_ACCOUNT_JSON 경로`);
} else {
try {
const sa = JSON.parse(existsSync(saRaw) ? readFileSync(saRaw, 'utf8') : saRaw);
const token = await googleToken(sa);
const siteUrl = `${URLBASE}/`;
const smUrl = `${URLBASE}/sitemap.xml`;
const put = await fetch(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(smUrl)}`, { method: 'PUT', headers: { authorization: `Bearer ${token}` } });
const g = { sitemapSubmit: put.status, inspected: [] };
if (put.status === 403 || put.status === 404) {
task('gsc-owner', 'Google Search Console 소유자 추가', `사이트맵 제출이 ${put.status} 로 거절됐습니다. 서비스 계정(${sa.client_email})이 속성 소유자가 아닙니다.`, `Search Console → 설정 → 사용자 및 권한 → ${sa.client_email} 을 소유자로 추가`);
} else {
for (const u of urls.slice(0, 20)) {
const r = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: u, siteUrl }) });
const j = r.ok ? await r.json() : null;
g.inspected.push({ url: u, verdict: j?.inspectionResult?.indexStatusResult?.verdict ?? `HTTP ${r.status}`, coverage: j?.inspectionResult?.indexStatusResult?.coverageState ?? '' });
}
g.indexed = g.inspected.filter((x) => x.verdict === 'PASS').length;
}
report.google = g;
} catch (e) { report.google = { error: String(e.message ?? e) }; task('gsc-error', 'Google Search Console 연동 오류 확인', String(e.message ?? e), 'GOOGLE_SERVICE_ACCOUNT_JSON 값과 서비스 계정 권한 확인'); }
}
// ---------- 4. 네이버 서치어드바이저 (공개 API 없음 → 사람) ----------
if (report.indexable) {
report.naver = { via: 'IndexNow 로 URL 알림' + (report.indexnow?.status ? ` (${report.indexnow.status})` : ''), verificationMeta: Boolean(site.naverSiteVerification) };
task('naver-register', '네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵 제출 (첫 1회)', '네이버는 사이트 등록과 사이트맵·RSS 제출에 공개 API 가 없습니다. IndexNow 로 URL 은 알렸지만 등록이 돼 있어야 수집 현황을 볼 수 있습니다.',
`1) searchadvisor.naver.com 에 ${URLBASE} 등록 2) 소유 확인: HTML 태그 방식의 content 값을 site.json naverSiteVerification 에 넣고 재빌드 3) 요청 → 사이트맵 제출 ${URLBASE}/sitemap.xml 4) 요청 → 웹 페이지 수집(하루 50건)에 홈·주요 글 URL`);
task('naver-track', '네이버 블로그 재작성 트랙 시작', '네이버 블로그·플레이스·카페는 AI 크롤러를 막고 있어 네이버 노출은 별도 트랙입니다(설계 v0.1 §8).', '허브 발행 24시간 뒤 요약 재작성본(60% 이하 분량)에 허브 링크. 병원 명의 여부는 §7 사전심의 확인 뒤');
task('backlinks', '병원 자산에서 서포터즈로 링크·sameAs 연결', '새 도메인은 권위가 0 이라 색인이 몇 주 걸릴 수 있습니다. 병원 홈페이지·유튜브 채널 설명·강남언니·구글 비즈니스 프로필에서 연결하면 빨라집니다.', '병원 담당자에게 링크 추가 요청. 서포터즈 JSON-LD 의 sameAs 는 이미 병원 자산을 가리킨다');
} else {
report.naver = { skipped: '색인 허용 빌드가 아님' };
task('go-indexable', '색인 허용 전환 결정', '지금은 샘플(noindex) 상태입니다. 전환 전에 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)과 글 게시 승인(approvedAt)이 끝나야 합니다.', `결정되면 워커를 --indexable --deploy 로 재실행. 도메인이 정해졌으면 --domain <도메인> 을 함께`);
}
const isVercelDefault = /^https:\/\/[a-z0-9-]+\.vercel\.app$/.test(URLBASE);
if (isVercelDefault) {
task('domain', '정식 도메인 연결', '지금 주소는 Vercel 기본 도메인입니다. 정식 도메인(별도 도메인 vs 병원 서브도메인, 설계 v0.1 §3)이 정해져야 색인 작업이 의미가 있습니다.', '도메인 결정 → 워커 --domain <도메인> (Vercel 프로젝트에 도메인 추가) → DNS 설정 → 재실행');
}
// ---------- 5. 체크리스트 ----------
mkdirSync(WORK, { recursive: true });
const md = [`# 발행 체크리스트 · ${fact.shortName || clinic} · ${today}`, '', `라이브 ${URLBASE} · 홈 ${home.status} · robots ${robots.status} · sitemap ${sitemap.status} (URL ${urls.length}) · 색인 허용 ${report.indexable ? '예' : '아니오(noindex)'}`, '',
`## 자동으로 한 것`, `- IndexNow: ${report.indexnow?.status ? `제출 ${report.indexnow.submitted}건, 응답 ${report.indexnow.status}` : (report.indexnow?.skipped || report.indexnow?.error || '없음')}`,
`- Google Search Console: ${report.google?.sitemapSubmit ? `사이트맵 제출 ${report.google.sitemapSubmit}, 색인 확인 ${report.google.indexed ?? 0}/${report.google.inspected?.length ?? 0}` : (report.google?.skipped || report.google?.error || '없음')}`, '',
`## 사람이 할 것 (이것까지 끝나야 완료)`, ...report.humanTasks.map((t, i) => `${i + 1}. **${t.title}**\n - 왜: ${t.why}\n - 어떻게: ${t.how}`), ''].join('\n');
writeFileSync(join(WORK, 'publish-checklist.md'), md);
writeFileSync(join(WORK, 'publish-report.json'), JSON.stringify(report, null, 2));
console.log(md);
console.log(`__PUBLISH_JSON__${JSON.stringify({ live: report.live, indexable: report.indexable, indexnow: report.indexnow, google: report.google && { sitemapSubmit: report.google.sitemapSubmit, indexed: report.google.indexed, skipped: report.google.skipped, error: report.google.error }, humanTasks: report.humanTasks.map((t) => t.title) })}`);

View File

@ -24,5 +24,9 @@
"sponsorNotice": "", "sponsorNotice": "",
"ga4MeasurementId": "", "ga4MeasurementId": "",
"_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.", "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.",
"heroImageAltEn": "" "heroImageAltEn": "",
"indexNowKey": "",
"googleSiteVerification": "",
"naverSiteVerification": "",
"_comment_publish": "indexNowKey 는 워커 data 단계가 자동 생성(public/<key>.txt). googleSiteVerification·naverSiteVerification 은 각 콘솔의 HTML 태그 content 값을 사람이 넣는다(briefs/<clinic>/clinic.json site 로 전달 가능). 발행 단계는 scripts/publish_site.mjs."
} }

View File

@ -150,5 +150,8 @@
], ],
"newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.", "newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.",
"newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등", "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등",
"heroImageAltEn": "View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor" "heroImageAltEn": "View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor",
"indexNowKey": "",
"googleSiteVerification": "",
"naverSiteVerification": ""
} }

View File

@ -0,0 +1,514 @@
---
// 외국인 환자용 주변 안내(/en/stay). /stay 의 영문 짝이다. haewon 결정(2026-09-11): 주변 안내(/en/stay)와 회복 일정 플래너(/en/plan)는 별개 페이지다.
// 1. Around the clinic : 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals : 계절로 묶은 행사
// 3. Suggested days : 시간표와 지도가 붙은 동선
// + After-treatment care(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장(번역)
//
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소·도시는 factSheet, 기준점은
// medicalTourism.meta.origin 에서만 읽는다. 이 파일에 특정 병원이나 지역 이름을 적지 않는다.
// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "pending" 안내를 그리고 빌드는 그대로 된다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has } from '../../lib';
import data from '../../data/medicalTourism.json';
import recovery from '../../data/recoveryNotes.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 확인 대기로 그린다.
const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string };
const ready = Boolean(origin.lat && origin.lng);
const total: number = T.filters?.total ?? 0;
const festivals: any[] = T.festivals ?? [];
const updated: string = (T.meta?.fetchedAt || '').slice(0, 10);
// 거리의 기준 주소. 영문 주소가 있으면 그것, 없으면 수집 때 쓴 주소, 그다음 팩트 시트 국문 주소.
const originAddress = f.address?.fullEn || origin.address || f.address?.full || '';
// 도시 이름은 영문 주소의 마지막 항목("..., Gangnam-gu, Seoul" 이면 Seoul)에서 읽는다. 없으면 Korea.
const cityEn = (f.address?.fullEn || '').split(',').pop()?.trim() || 'Korea';
const festKm: number = T.meta?.festivalRadiusKm || 0;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' },
{ key: 'stay', label: 'Hotels', note: 'Places to sleep' },
{ key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' },
{ key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' },
{ key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' },
{ key: 'shopping', label: 'Shopping', note: 'Stores and markets' },
];
const SEASONS = [
{ key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' },
{ key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' },
];
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(`${q} ${cityEn}`)}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0;
const pick = (key: string, maxMin: number, n: number): Place[] =>
(((T.places ?? {})[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
blurb: 'Everything within a short walk. You do not need a taxi.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, 'Your appointment ends here.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
blurb: 'Still close, but out of the block.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, 'Start from the clinic.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: 'When you can travel', who: 'Later in your stay',
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, 'Set off from the clinic.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다.
// en 이 비어 있는 문장(번역 실패·키 없음)은 한글 원문을 그대로 보이고 (Korean) 표시를 붙인다. items 가 비어 있으면 블록을 그리지 않는다.
type RecoveryItem = { procedure: string; procedureEn?: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean };
const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] };
const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length);
const recoveryDate = RN.generatedAt || '';
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
(((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Recovery schedule at ${clinic}`, url: `${site}/en/stay`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
lang="en"
title={`Around ${clinic}: food, rest, sights, festivals, day routes`}
description={`For international patients at ${clinic} in ${cityEn}: where to eat and rest, what to see, which festivals are on, and how to shape your days. Distances and walking times from the clinic. Source: Korea Tourism Organization.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">Nearby</div>
<h1 class="serif">Around The Clinic</h1>
<p class="lede">
<strong>Around {clinic}.</strong> {ready
? `Everything below is measured from the clinic at ${originAddress || 'its address'}.`
: 'Distances and walking times will appear once the clinic\'s location is confirmed.'}
Names, photographs and descriptions come from the Korea Tourism Organization.
For the clinic's own hours and phone, see <a href="/clinic" hreflang="ko">Clinic information<span class="lang-tag">KO</span></a>. To plan arrival and departure around your surgery date, use the <a href="/en/plan">Recovery Planner</a>.
</p>
{!ready && <p class="pending-note">This guide is pending. Food, hotels, rest, sights and festivals near the clinic will be listed once its address is confirmed.</p>}
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">Loading</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="Jump to a section">
{recoveryItems.length > 0 && <a href="#recovery-quote">Care<b>{recoveryItems.length}</b></a>}
{GROUPS.filter((g) => allPlaces.some((p) => p.cat === g.key)).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{allPlaces.filter((p) => p.cat === g.key).length}</b></a>
))}
<a href="#festivals">Festivals<b>{festivals.length}</b></a>
<a href="#days">Days<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 0. After-treatment care ── 병원 시술 페이지 원문 인용(번역). 기본은 접힘, summary 가 버튼이다(haewon 2026-09-11).
동선 카드(.route)·정차(.stop) 룩을 그대로 쓴다. id="recovery-quote" 는 발행 게이트(check.mjs)가 홈페이지 40자 일치 검사에서 이 인용 블록만 빼는 표식이다. -->
{recoveryItems.length > 0 && (
<details class="route recovery" id="recovery-quote">
<summary class="btn primary"><span class="show">Show after-treatment care · {recoveryItems.length} treatments</span><span class="hide">Hide after-treatment care</span></summary>
<div class="route-head">
<div>
<h3>After-treatment care, as published by the clinic</h3>
<p class="who">From {clinic}'s own treatment pages · {recoveryItems.length} treatments</p>
</div>
{recoveryDate && <div class="clock">Checked {recoveryDate}</div>}
</div>
<p class="blurb">These lines are quoted from {clinic}'s own treatment pages (Korean), translated for reference. They differ by treatment and by person. Confirm at your consultation.</p>
<ol class="timeline">
{recoveryItems.map((it, i) => (
<li>
<details open={i < 3}>
<summary>
<span class="stop">
<span class="n">{i + 1}</span>
<span>
<span class="stop-t">{it.area ? `${it.area} · ` : ''}{it.ko.length} {it.ko.length === 1 ? 'line' : 'lines'} · tap to open</span>
<h4>{it.procedureEn || it.procedure}</h4>
{it.procedureEn && <span class="stop-t" lang="ko">{it.procedure}</span>}
</span>
</span>
</summary>
<ul>
{it.ko.map((s, k) => it.en?.[k]
? <li>{it.en[k]}</li>
: <li lang="ko">{s} <span class="lang-tag">Korean</span></li>)}
</ul>
<p><a class="go" href={it.url} rel="noopener nofollow" hreflang="ko">Clinic page (Korean) ↗</a>{it.fetchedAt ? ` · checked ${it.fetchedAt}` : ''}</p>
</details>
</li>
))}
</ol>
</details>
)}
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>Around the clinic</h2>
<span class="upd">{updated ? `Updated ${updated}` : 'Pending'}</span>
</div>
<p class="sub">{total > 0 ? `${total} places, sorted by how long they take to reach on foot.` : 'Listings will follow once the clinic\'s location is confirmed.'}</p>
{total > 0 && <div class="chips" id="walkchips" role="group" aria-label="Filter by travel time">
<button class="chip on" data-max="all">All <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">Within 5 min walk <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">Within 15 min walk <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">Walking distance <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">By car <b>{T.filters.drive}</b></button>
</div>}
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title} : ${g.label.toLowerCase()} near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">Official site ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>Festivals and events</h2></div>
<p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}</p>
<p class="desc">{firstSentence(e.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>
</div>
</article>
))}
</div>
<p class="src">Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>Shaping your days</h2></div>
<p class="sub">
Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule.
</p>
{Sx.recoveryStagesEn
? <p><strong>What {clinic} advises.</strong> {Sx.recoveryStagesEn}</p>
: <p class="pending-note">Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`Map of ${pins.pins.length} stops: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>What this page does not tell you</h2>
<ul>
<li>No ratings, review counts or opening hours. The tourism data does not carry them.</li>
<li>No claim that any place suits your recovery. That is for {clinic} to say.</li>
<li>{total > 0
? `English tourism listings near the clinic are limited. Only ${T.filters.walk5} places sit within a five minute walk, and ${T.filters.drive} of ${total} need a car.`
: 'Listings near the clinic are pending until its location is confirmed.'}</li>
<li>We are not a travel agency, take no booking and no commission.</li>
</ul>
<p class="botnote">
{has(f.urlEn) && <a class="btn primary" href={f.urlEn} rel="noopener" hreflang="en">Book a consultation in English</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: origin.lat ?? 0, WX_LNG: origin.lng ?? 0 }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 점프 칩 '시술 후 관리'는 접힌 블록으로 가므로 눌렀을 때 함께 연다.
document.querySelectorAll('a[href="#recovery-quote"]').forEach(function (a) { a.addEventListener('click', function () { var d = document.getElementById('recovery-quote'); if (d) d.open = true; }); });
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
56: 'Freezing drizzle', 57: 'Freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Freezing rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Violent showers',
85: 'Snow showers', 86: 'Snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with hail'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
if (!LAT || !LNG) return; // 기준 좌표가 없으면(확인 대기) 날씨도 띄우지 않는다
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || 'Current conditions';
document.getElementById('wx-m').textContent =
'Feels like ' + Math.round(c.apparent_temperature) + '°C · wind ' +
Math.round(c.wind_speed_10m) + ' km/h · observed ' + String(c.time).replace('T', ' ');
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? 'Wet outside. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m >= 30
? 'Hot today. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m <= 0
? 'Below freezing. The Culture and Shopping listings below are indoors.'
: 'Dry at the moment. The walking times below are on foot from the clinic.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -12,6 +12,8 @@ export const GET: APIRoute = async ({ site }) => {
{ loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today }, { loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/en`, lastmod: today }, { loc: `${base}/en`, lastmod: today },
{ loc: `${base}/stay`, lastmod: today },
{ loc: `${base}/en/stay`, lastmod: today },
{ loc: `${base}/plan`, lastmod: today }, { loc: `${base}/plan`, lastmod: today },
{ loc: `${base}/en/plan`, lastmod: today }, { loc: `${base}/en/plan`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today },

View File

@ -0,0 +1,515 @@
---
// 주변 안내(한국어). /en/stay 의 한국어 짝이다. haewon 결정(2026-09-11): 주변 안내(/stay)와 회복 일정 플래너(/plan)는 별개 페이지다. 이 페이지는 병원 주변 장소·축제·동선이고, 시술 후 관리 안내는 접이식으로 둔다. 데이터는 국문 서비스(KorService2)라
// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건).
// 구조는 /en/stay 와 같다. 세 부분이다.
// 1. 병원 주변 : 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals : 계절로 묶은 행사
// 3. Suggested days : 시간표와 지도가 붙은 동선
// + 시술 후 관리 안내(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장
//
// 데이터는 한국관광공사 TourAPI 국문(KorService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourismKo.json 이다. 설명문은 detailCommon2 의 overview 다.
// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소는 factSheet, 기준점은
// medicalTourismKo.meta.origin 에서만 읽는다. 이 파일에 특정 병원을 적지 않는다.
// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "확인 대기"를 그리고 빌드는 그대로 된다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib';
import data from '../data/medicalTourismKo.json';
import recovery from '../data/recoveryNotes.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortName || '병원';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 "확인 대기"로 그린다.
const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string };
const ready = Boolean(origin.lat && origin.lng);
const total: number = T.filters?.total ?? 0;
const festivals: any[] = T.festivals ?? [];
const updated: string = (T.meta?.fetchedAt || '').slice(0, 10);
// 거리의 기준 주소. 수집 때 쓴 주소가 있으면 그것, 없으면 팩트 시트 주소.
const originAddress = origin.address || f.address?.full || '';
const festKm: number = T.meta?.festivalRadiusKm || 0;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: '식사', note: '음식점과 카페' },
{ key: 'stay', label: '숙박', note: '묵을 곳' },
{ key: 'wellness', label: '휴식', note: '스파와 찜질방' },
{ key: 'attraction', label: '관광', note: '거리, 공원, 명소' },
{ key: 'culture', label: '문화', note: '미술관, 공연장' },
{ key: 'shopping', label: '쇼핑', note: '백화점과 시장' },
];
const SEASONS = [
{ key: 'spring', label: '봄' }, { key: 'summer', label: '여름' },
{ key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' },
];
const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record<string, string>)[nowSeasonKey];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
// 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다.
const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${+d.slice(4, 6)}.${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
// travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다.
const travelLabel = (t: { mode: string; minutes: number } | null) =>
!t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`;
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0;
const pick = (key: string, maxMin: number, n: number): Place[] =>
(((T.places ?? {})[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: '진료 다녀오는 날', who: '병원에 자주 오는 동안',
blurb: '전부 걸어서 갈 수 있는 곳입니다. 택시가 필요 없습니다.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, '진료가 끝나는 곳입니다.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: '가까운 곳 나들이', who: '조금 더 걸을 수 있을 때',
blurb: '가깝지만 한 블록 밖입니다.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, '병원에서 출발합니다.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: '멀리 나갈 수 있을 때', who: '체류 후반에',
blurb: '시내 곳곳입니다. 택시나 지하철을 탑니다.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, '병원에서 나섭니다.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다.
// 병원 확정값(site.recoveryStagesKo)과 역할이 다르다. 확정값은 "병원 안내" 문단, 이것은 원문 인용이라 출처 링크와 확인일을 붙인다.
// items 가 비어 있으면(템플릿 기본값) 블록을 아예 그리지 않는다.
type RecoveryItem = { procedure: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean };
const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] };
const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length);
const recoveryDate = RN.generatedAt || '';
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
(((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
title={`${clinic} 주변 안내 · 식사, 숙박, 휴식, 관광, 축제, 하루 동선`}
description={`${clinic} 주변에서 어디서 먹고 묵고 쉴 수 있는지, 무엇을 볼 수 있는지, 어떤 축제가 열리는지를 병원에서의 거리와 도보 시간으로 정리했습니다. 출처는 한국관광공사입니다.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">Nearby</div>
<h1 class="serif">Around The Clinic</h1>
<p class="lede">
<strong>{clinic} 주변입니다.</strong> {ready
? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.`
: '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'}
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에, 수술일 기준 입국·출국 계산은 <a href="/plan">회복 일정</a>에 있습니다.
</p>
{!ready && <p class="pending-note">주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.</p>}
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">불러오는 중</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="섹션으로 이동">
{recoveryItems.length > 0 && <a href="#recovery-quote">시술 후 관리<b>{recoveryItems.length}</b></a>}
{GROUPS.filter((g) => allPlaces.some((p) => p.cat === g.key)).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{allPlaces.filter((p) => p.cat === g.key).length}</b></a>
))}
<a href="#festivals">축제<b>{festivals.length}</b></a>
<a href="#days">동선<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 0. 시술 후 관리 안내 ── 병원 시술 페이지 원문 인용. 기본은 접힘, summary 가 버튼이다(haewon 2026-09-11).
동선 카드(.route)·정차(.stop) 룩을 그대로 쓴다. id="recovery-quote" 는 발행 게이트(check.mjs)가 홈페이지 40자 일치 검사에서 이 인용 블록만 빼는 표식이다. -->
{recoveryItems.length > 0 && (
<details class="route recovery" id="recovery-quote">
<summary class="btn primary"><span class="show">시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개</span><span class="hide">시술 후 관리 안내 접기</span></summary>
<div class="route-head">
<div>
<h3>시술 후 관리 안내</h3>
<p class="who">{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개</p>
</div>
{recoveryDate && <div class="clock">{recoveryDate} 확인</div>}
</div>
<p class="blurb">아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.</p>
<ol class="timeline">
{recoveryItems.map((it, i) => (
<li>
<details open={i < 3}>
<summary>
<span class="stop">
<span class="n">{i + 1}</span>
<span>
<span class="stop-t">{it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기</span>
<h4>{it.procedure}</h4>
</span>
</span>
</summary>
<ul>
{it.ko.map((s) => <li>{s}</li>)}
</ul>
<p><a class="go" href={it.url} rel="noopener nofollow">병원 페이지 보기 ↗</a>{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}</p>
</details>
</li>
))}
</ol>
</details>
)}
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>병원 주변</h2>
<span class="upd">{updated ? `${updated} 갱신` : '확인 대기'}</span>
</div>
<p class="sub">{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}</p>
{total > 0 && <div class="chips" id="walkchips" role="group" aria-label="이동 시간으로 거르기">
<button class="chip on" data-max="all">전체 <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">도보 5분 이내 <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">도보 15분 이내 <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">걸어갈 수 있는 곳 <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">차로 가는 곳 <b>{T.filters.drive}</b></button>
</div>}
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title}, ${clinic} 주변 ${g.label}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{travelLabel(p.travel)} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">공식 홈페이지 ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>계절별 축제</h2></div>
<p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}</p>
<p class="desc">{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>
</div>
</article>
))}
</div>
<p class="src">출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>하루 동선</h2></div>
<p class="sub">
위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다.
</p>
{Sx.recoveryStagesKo
? <p><strong>{clinic} 안내.</strong> {Sx.recoveryStagesKo}</p>
: <p class="pending-note">어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`동선 지도, 정차 ${pins.pins.length}곳: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin}분</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>이 페이지가 답하지 못하는 것</h2>
<ul>
<li>평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.</li>
<li>어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.</li>
<li>{total > 0
? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.`
: '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}</li>
<li>여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.</li>
</ul>
<p class="botnote">
{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: origin.lat ?? 0, WX_LNG: origin.lng ?? 0 }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 점프 칩 '시술 후 관리'는 접힌 블록으로 가므로 눌렀을 때 함께 연다.
document.querySelectorAll('a[href="#recovery-quote"]').forEach(function (a) { a.addEventListener('click', function () { var d = document.getElementById('recovery-quote'); if (d) d.open = true; }); });
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: '맑음', 1: '대체로 맑음', 2: '구름 조금', 3: '흐림',
45: '안개', 48: '서리 안개', 51: '약한 이슬비', 53: '이슬비', 55: '강한 이슬비',
56: '언 이슬비', 57: '언 이슬비', 61: '약한 비', 63: '비', 65: '강한 비',
66: '언 비', 67: '언 비', 71: '약한 눈', 73: '눈', 75: '강한 눈',
77: '싸락눈', 80: '약한 소나기', 81: '소나기', 82: '강한 소나기',
85: '진눈깨비', 86: '진눈깨비', 95: '뇌우', 96: '우박을 동반한 뇌우', 99: '우박을 동반한 뇌우'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
if (!LAT || !LNG) return; // 기준 좌표가 없으면(확인 대기) 날씨도 띄우지 않는다
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || '현재 날씨';
document.getElementById('wx-m').textContent =
'체감 ' + Math.round(c.apparent_temperature) + '°C · 바람 ' +
Math.round(c.wind_speed_10m) + ' km/h · ' + String(c.time).replace('T', ' ') + ' 관측';
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? '비가 옵니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m >= 30
? '더운 날입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m <= 0
? '영하입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: '지금은 비가 오지 않습니다. 아래 시간은 병원에서 걸어가는 기준입니다.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -28,20 +28,10 @@
"destination": "/plan", "destination": "/plan",
"permanent": true "permanent": true
}, },
{
"source": "/stay",
"destination": "/plan",
"permanent": true
},
{ {
"source": "/en/recovery", "source": "/en/recovery",
"destination": "/en/plan", "destination": "/en/plan",
"permanent": true "permanent": true
},
{
"source": "/en/stay",
"destination": "/en/plan",
"permanent": true
} }
] ]
} }

View File

@ -9,6 +9,4 @@ export default defineConfig({
trailingSlash: 'never', trailingSlash: 'never',
build: { format: 'file' }, build: { format: 'file' },
compressHTML: true, compressHTML: true,
// 주변 안내(/stay)는 회복 일정(/recovery)으로 합쳤다(haewon 2026-09-11). 정적 빌드라 meta refresh 페이지가 생긴다.
redirects: { '/stay': '/recovery', '/en/stay': '/en/recovery' },
}); });

View File

@ -0,0 +1,127 @@
// 배포 후 색인 작업 (워커 publish 단계). "서포터즈 사이트 완료"는 배포가 아니라 이 단계와 사람 체크리스트까지 끝난 상태다 (haewon 결정 2026-09-11).
// node scripts/publish_site.mjs --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]
//
// 자동으로 하는 것
// 1. 라이브 확인: 홈·robots.txt·sitemap.xml 응답, X-Robots-Tag(noindex) 여부, 사이트맵 URL 수
// 2. IndexNow(빙·네이버 공용): 색인 허용 빌드일 때만 사이트맵의 URL 전부를 api.indexnow.org 에 알린다. 키 파일은 워커 data 단계가 public/<key>.txt 로 넣는다
// 3. Google Search Console: GOOGLE_SERVICE_ACCOUNT_JSON(경로 또는 JSON) 이 있으면 사이트맵 제출 + 최대 20 URL 색인 상태 조회(URL Inspection API).
// 서비스 계정이 속성 소유자로 등록돼 있지 않으면 403 → 사람 체크리스트로 넘긴다
// 사람에게 남기는 것 (<work>/publish-checklist.md, status.humanTasks)
// 네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유 확인(첫 1회), 도메인 연결, 색인 허용 전환 전 법무·승인 확인
// 하지 않는 것: Google Indexing API(채용·라이브 방송 전용, 일반 페이지에 쓰면 가이드라인 위반), 색인 보장 약속.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createSign } from 'node:crypto';
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic'); const SITE = opt('site'); const URLBASE = (opt('url') || '').replace(/\/$/, '');
if (!clinic || !SITE || !URLBASE) { console.error('사용법: --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]'); process.exit(2); }
const WORK = resolve(opt('work', join(SITE, '..')));
const site = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'site.json'), 'utf8'));
const fact = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'factSheet.json'), 'utf8'));
const today = new Date().toISOString().slice(0, 10);
const report = { clinic, url: URLBASE, checkedAt: new Date().toISOString(), live: {}, indexable: false, indexnow: null, google: null, naver: null, humanTasks: [] };
const task = (id, title, why, how) => report.humanTasks.push({ id, title, why, how });
async function head(url) {
try { const r = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'infinith-supporters-publish/1.0' } }); const text = r.ok ? await r.text() : ''; return { ok: r.ok, status: r.status, robotsTag: r.headers.get('x-robots-tag') || '', text }; }
catch (e) { return { ok: false, status: 0, robotsTag: '', text: '', error: String(e.message ?? e) }; }
}
// ---------- 1. 라이브 확인 ----------
const home = await head(`${URLBASE}/`);
const robots = await head(`${URLBASE}/robots.txt`);
const sitemap = await head(`${URLBASE}/sitemap.xml`);
const urls = [...(sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g))].map((m) => m[1].trim());
const noindexHeader = /noindex/i.test(home.robotsTag);
const robotsBlocksAll = /Disallow:\s*\/\s*$/m.test(robots.text) && !/Allow:\s*\//.test(robots.text);
report.live = { home: home.status, robots: robots.status, sitemap: sitemap.status, sitemapUrls: urls.length, noindexHeader, robotsBlocksAll };
report.indexable = flag('indexable') || (home.ok && !noindexHeader && !robotsBlocksAll);
if (!home.ok) task('live', '라이브 응답 확인', `홈이 ${home.status} 로 응답했습니다.`, `${URLBASE}/ 를 열어 배포 상태를 확인`);
// ---------- 2. IndexNow (빙·네이버) ----------
if (report.indexable && urls.length) {
const key = site.indexNowKey;
if (!key) { report.indexnow = { skipped: 'site.json 에 indexNowKey 없음 (워커 data 단계가 만든다)' }; }
else {
const keyUrl = `${URLBASE}/${key}.txt`;
const kf = await head(keyUrl);
if (!kf.ok || kf.text.trim() !== key) { report.indexnow = { skipped: `키 파일 확인 실패 ${keyUrl} (${kf.status})` }; }
else {
try {
const host = new URL(URLBASE).host;
const r = await fetch('https://api.indexnow.org/indexnow', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls.slice(0, 10000) }) });
report.indexnow = { status: r.status, submitted: Math.min(urls.length, 10000), engines: 'Bing·Naver(IndexNow 참여)' };
} catch (e) { report.indexnow = { error: String(e.message ?? e) }; }
}
}
} else {
report.indexnow = { skipped: report.indexable ? '사이트맵 URL 없음' : '색인 허용 빌드가 아님(noindex). 샘플 단계에서는 알리지 않는다' };
}
// ---------- 3. Google Search Console ----------
async function googleToken(sa) {
const now = Math.floor(Date.now() / 1000);
const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const unsigned = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`;
const sig = createSign('RSA-SHA256').update(unsigned).sign(sa.private_key, 'base64url');
const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${unsigned}.${sig}` });
if (!r.ok) throw new Error(`토큰 실패 ${r.status}`);
return (await r.json()).access_token;
}
const saRaw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || '';
if (!report.indexable) {
report.google = { skipped: '색인 허용 빌드가 아님' };
} else if (!saRaw) {
report.google = { skipped: 'GOOGLE_SERVICE_ACCOUNT_JSON 없음' };
task('gsc-owner', 'Google Search Console 속성 등록·소유 확인 (첫 1회)', '서비스 계정이 없거나 속성 소유자로 등록되지 않아 사이트맵 제출과 색인 상태 조회를 자동으로 할 수 없습니다.', `1) Search Console 에 ${URLBASE} 속성 추가 2) 소유 확인: site.json googleSiteVerification 에 메타 태그 값(content)을 넣고 재빌드 3) 서비스 계정 이메일을 속성 소유자로 추가 4) Productize .env 에 GOOGLE_SERVICE_ACCOUNT_JSON 경로`);
} else {
try {
const sa = JSON.parse(existsSync(saRaw) ? readFileSync(saRaw, 'utf8') : saRaw);
const token = await googleToken(sa);
const siteUrl = `${URLBASE}/`;
const smUrl = `${URLBASE}/sitemap.xml`;
const put = await fetch(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(smUrl)}`, { method: 'PUT', headers: { authorization: `Bearer ${token}` } });
const g = { sitemapSubmit: put.status, inspected: [] };
if (put.status === 403 || put.status === 404) {
task('gsc-owner', 'Google Search Console 소유자 추가', `사이트맵 제출이 ${put.status} 로 거절됐습니다. 서비스 계정(${sa.client_email})이 속성 소유자가 아닙니다.`, `Search Console → 설정 → 사용자 및 권한 → ${sa.client_email} 을 소유자로 추가`);
} else {
for (const u of urls.slice(0, 20)) {
const r = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: u, siteUrl }) });
const j = r.ok ? await r.json() : null;
g.inspected.push({ url: u, verdict: j?.inspectionResult?.indexStatusResult?.verdict ?? `HTTP ${r.status}`, coverage: j?.inspectionResult?.indexStatusResult?.coverageState ?? '' });
}
g.indexed = g.inspected.filter((x) => x.verdict === 'PASS').length;
}
report.google = g;
} catch (e) { report.google = { error: String(e.message ?? e) }; task('gsc-error', 'Google Search Console 연동 오류 확인', String(e.message ?? e), 'GOOGLE_SERVICE_ACCOUNT_JSON 값과 서비스 계정 권한 확인'); }
}
// ---------- 4. 네이버 서치어드바이저 (공개 API 없음 → 사람) ----------
if (report.indexable) {
report.naver = { via: 'IndexNow 로 URL 알림' + (report.indexnow?.status ? ` (${report.indexnow.status})` : ''), verificationMeta: Boolean(site.naverSiteVerification) };
task('naver-register', '네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵 제출 (첫 1회)', '네이버는 사이트 등록과 사이트맵·RSS 제출에 공개 API 가 없습니다. IndexNow 로 URL 은 알렸지만 등록이 돼 있어야 수집 현황을 볼 수 있습니다.',
`1) searchadvisor.naver.com 에 ${URLBASE} 등록 2) 소유 확인: HTML 태그 방식의 content 값을 site.json naverSiteVerification 에 넣고 재빌드 3) 요청 → 사이트맵 제출 ${URLBASE}/sitemap.xml 4) 요청 → 웹 페이지 수집(하루 50건)에 홈·주요 글 URL`);
task('naver-track', '네이버 블로그 재작성 트랙 시작', '네이버 블로그·플레이스·카페는 AI 크롤러를 막고 있어 네이버 노출은 별도 트랙입니다(설계 v0.1 §8).', '허브 발행 24시간 뒤 요약 재작성본(60% 이하 분량)에 허브 링크. 병원 명의 여부는 §7 사전심의 확인 뒤');
task('backlinks', '병원 자산에서 서포터즈로 링크·sameAs 연결', '새 도메인은 권위가 0 이라 색인이 몇 주 걸릴 수 있습니다. 병원 홈페이지·유튜브 채널 설명·강남언니·구글 비즈니스 프로필에서 연결하면 빨라집니다.', '병원 담당자에게 링크 추가 요청. 서포터즈 JSON-LD 의 sameAs 는 이미 병원 자산을 가리킨다');
} else {
report.naver = { skipped: '색인 허용 빌드가 아님' };
task('go-indexable', '색인 허용 전환 결정', '지금은 샘플(noindex) 상태입니다. 전환 전에 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)과 글 게시 승인(approvedAt)이 끝나야 합니다.', `결정되면 워커를 --indexable --deploy 로 재실행. 도메인이 정해졌으면 --domain <도메인> 을 함께`);
}
const isVercelDefault = /^https:\/\/[a-z0-9-]+\.vercel\.app$/.test(URLBASE);
if (isVercelDefault) {
task('domain', '정식 도메인 연결', '지금 주소는 Vercel 기본 도메인입니다. 정식 도메인(별도 도메인 vs 병원 서브도메인, 설계 v0.1 §3)이 정해져야 색인 작업이 의미가 있습니다.', '도메인 결정 → 워커 --domain <도메인> (Vercel 프로젝트에 도메인 추가) → DNS 설정 → 재실행');
}
// ---------- 5. 체크리스트 ----------
mkdirSync(WORK, { recursive: true });
const md = [`# 발행 체크리스트 · ${fact.shortName || clinic} · ${today}`, '', `라이브 ${URLBASE} · 홈 ${home.status} · robots ${robots.status} · sitemap ${sitemap.status} (URL ${urls.length}) · 색인 허용 ${report.indexable ? '예' : '아니오(noindex)'}`, '',
`## 자동으로 한 것`, `- IndexNow: ${report.indexnow?.status ? `제출 ${report.indexnow.submitted}건, 응답 ${report.indexnow.status}` : (report.indexnow?.skipped || report.indexnow?.error || '없음')}`,
`- Google Search Console: ${report.google?.sitemapSubmit ? `사이트맵 제출 ${report.google.sitemapSubmit}, 색인 확인 ${report.google.indexed ?? 0}/${report.google.inspected?.length ?? 0}` : (report.google?.skipped || report.google?.error || '없음')}`, '',
`## 사람이 할 것 (이것까지 끝나야 완료)`, ...report.humanTasks.map((t, i) => `${i + 1}. **${t.title}**\n - 왜: ${t.why}\n - 어떻게: ${t.how}`), ''].join('\n');
writeFileSync(join(WORK, 'publish-checklist.md'), md);
writeFileSync(join(WORK, 'publish-report.json'), JSON.stringify(report, null, 2));
console.log(md);
console.log(`__PUBLISH_JSON__${JSON.stringify({ live: report.live, indexable: report.indexable, indexnow: report.indexnow, google: report.google && { sitemapSubmit: report.google.sitemapSubmit, indexed: report.google.indexed, skipped: report.google.skipped, error: report.google.error }, humanTasks: report.humanTasks.map((t) => t.title) })}`);

View File

@ -0,0 +1,514 @@
---
// 외국인 환자용 주변 안내(/en/stay). /stay 의 영문 짝이다. haewon 결정(2026-09-11): 주변 안내(/en/stay)와 회복 일정 플래너(/en/plan)는 별개 페이지다.
// 1. Around the clinic : 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals : 계절로 묶은 행사
// 3. Suggested days : 시간표와 지도가 붙은 동선
// + After-treatment care(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장(번역)
//
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소·도시는 factSheet, 기준점은
// medicalTourism.meta.origin 에서만 읽는다. 이 파일에 특정 병원이나 지역 이름을 적지 않는다.
// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "pending" 안내를 그리고 빌드는 그대로 된다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has } from '../../lib';
import data from '../../data/medicalTourism.json';
import recovery from '../../data/recoveryNotes.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 확인 대기로 그린다.
const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string };
const ready = Boolean(origin.lat && origin.lng);
const total: number = T.filters?.total ?? 0;
const festivals: any[] = T.festivals ?? [];
const updated: string = (T.meta?.fetchedAt || '').slice(0, 10);
// 거리의 기준 주소. 영문 주소가 있으면 그것, 없으면 수집 때 쓴 주소, 그다음 팩트 시트 국문 주소.
const originAddress = f.address?.fullEn || origin.address || f.address?.full || '';
// 도시 이름은 영문 주소의 마지막 항목("..., Gangnam-gu, Seoul" 이면 Seoul)에서 읽는다. 없으면 Korea.
const cityEn = (f.address?.fullEn || '').split(',').pop()?.trim() || 'Korea';
const festKm: number = T.meta?.festivalRadiusKm || 0;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' },
{ key: 'stay', label: 'Hotels', note: 'Places to sleep' },
{ key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' },
{ key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' },
{ key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' },
{ key: 'shopping', label: 'Shopping', note: 'Stores and markets' },
];
const SEASONS = [
{ key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' },
{ key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' },
];
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(`${q} ${cityEn}`)}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0;
const pick = (key: string, maxMin: number, n: number): Place[] =>
(((T.places ?? {})[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
blurb: 'Everything within a short walk. You do not need a taxi.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, 'Your appointment ends here.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
blurb: 'Still close, but out of the block.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, 'Start from the clinic.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: 'When you can travel', who: 'Later in your stay',
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, 'Set off from the clinic.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다.
// en 이 비어 있는 문장(번역 실패·키 없음)은 한글 원문을 그대로 보이고 (Korean) 표시를 붙인다. items 가 비어 있으면 블록을 그리지 않는다.
type RecoveryItem = { procedure: string; procedureEn?: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean };
const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] };
const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length);
const recoveryDate = RN.generatedAt || '';
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
(((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Recovery schedule at ${clinic}`, url: `${site}/en/stay`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
lang="en"
title={`Around ${clinic}: food, rest, sights, festivals, day routes`}
description={`For international patients at ${clinic} in ${cityEn}: where to eat and rest, what to see, which festivals are on, and how to shape your days. Distances and walking times from the clinic. Source: Korea Tourism Organization.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">Nearby</div>
<h1 class="serif">Around The Clinic</h1>
<p class="lede">
<strong>Around {clinic}.</strong> {ready
? `Everything below is measured from the clinic at ${originAddress || 'its address'}.`
: 'Distances and walking times will appear once the clinic\'s location is confirmed.'}
Names, photographs and descriptions come from the Korea Tourism Organization.
For the clinic's own hours and phone, see <a href="/clinic" hreflang="ko">Clinic information<span class="lang-tag">KO</span></a>. To plan arrival and departure around your surgery date, use the <a href="/en/plan">Recovery Planner</a>.
</p>
{!ready && <p class="pending-note">This guide is pending. Food, hotels, rest, sights and festivals near the clinic will be listed once its address is confirmed.</p>}
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">Loading</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="Jump to a section">
{recoveryItems.length > 0 && <a href="#recovery-quote">Care<b>{recoveryItems.length}</b></a>}
{GROUPS.filter((g) => allPlaces.some((p) => p.cat === g.key)).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{allPlaces.filter((p) => p.cat === g.key).length}</b></a>
))}
<a href="#festivals">Festivals<b>{festivals.length}</b></a>
<a href="#days">Days<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 0. After-treatment care ── 병원 시술 페이지 원문 인용(번역). 기본은 접힘, summary 가 버튼이다(haewon 2026-09-11).
동선 카드(.route)·정차(.stop) 룩을 그대로 쓴다. id="recovery-quote" 는 발행 게이트(check.mjs)가 홈페이지 40자 일치 검사에서 이 인용 블록만 빼는 표식이다. -->
{recoveryItems.length > 0 && (
<details class="route recovery" id="recovery-quote">
<summary class="btn primary"><span class="show">Show after-treatment care · {recoveryItems.length} treatments</span><span class="hide">Hide after-treatment care</span></summary>
<div class="route-head">
<div>
<h3>After-treatment care, as published by the clinic</h3>
<p class="who">From {clinic}'s own treatment pages · {recoveryItems.length} treatments</p>
</div>
{recoveryDate && <div class="clock">Checked {recoveryDate}</div>}
</div>
<p class="blurb">These lines are quoted from {clinic}'s own treatment pages (Korean), translated for reference. They differ by treatment and by person. Confirm at your consultation.</p>
<ol class="timeline">
{recoveryItems.map((it, i) => (
<li>
<details open={i < 3}>
<summary>
<span class="stop">
<span class="n">{i + 1}</span>
<span>
<span class="stop-t">{it.area ? `${it.area} · ` : ''}{it.ko.length} {it.ko.length === 1 ? 'line' : 'lines'} · tap to open</span>
<h4>{it.procedureEn || it.procedure}</h4>
{it.procedureEn && <span class="stop-t" lang="ko">{it.procedure}</span>}
</span>
</span>
</summary>
<ul>
{it.ko.map((s, k) => it.en?.[k]
? <li>{it.en[k]}</li>
: <li lang="ko">{s} <span class="lang-tag">Korean</span></li>)}
</ul>
<p><a class="go" href={it.url} rel="noopener nofollow" hreflang="ko">Clinic page (Korean) ↗</a>{it.fetchedAt ? ` · checked ${it.fetchedAt}` : ''}</p>
</details>
</li>
))}
</ol>
</details>
)}
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>Around the clinic</h2>
<span class="upd">{updated ? `Updated ${updated}` : 'Pending'}</span>
</div>
<p class="sub">{total > 0 ? `${total} places, sorted by how long they take to reach on foot.` : 'Listings will follow once the clinic\'s location is confirmed.'}</p>
{total > 0 && <div class="chips" id="walkchips" role="group" aria-label="Filter by travel time">
<button class="chip on" data-max="all">All <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">Within 5 min walk <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">Within 15 min walk <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">Walking distance <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">By car <b>{T.filters.drive}</b></button>
</div>}
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title} : ${g.label.toLowerCase()} near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">Official site ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>Festivals and events</h2></div>
<p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}</p>
<p class="desc">{firstSentence(e.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>
</div>
</article>
))}
</div>
<p class="src">Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>Shaping your days</h2></div>
<p class="sub">
Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule.
</p>
{Sx.recoveryStagesEn
? <p><strong>What {clinic} advises.</strong> {Sx.recoveryStagesEn}</p>
: <p class="pending-note">Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`Map of ${pins.pins.length} stops: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>What this page does not tell you</h2>
<ul>
<li>No ratings, review counts or opening hours. The tourism data does not carry them.</li>
<li>No claim that any place suits your recovery. That is for {clinic} to say.</li>
<li>{total > 0
? `English tourism listings near the clinic are limited. Only ${T.filters.walk5} places sit within a five minute walk, and ${T.filters.drive} of ${total} need a car.`
: 'Listings near the clinic are pending until its location is confirmed.'}</li>
<li>We are not a travel agency, take no booking and no commission.</li>
</ul>
<p class="botnote">
{has(f.urlEn) && <a class="btn primary" href={f.urlEn} rel="noopener" hreflang="en">Book a consultation in English</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: origin.lat ?? 0, WX_LNG: origin.lng ?? 0 }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 점프 칩 '시술 후 관리'는 접힌 블록으로 가므로 눌렀을 때 함께 연다.
document.querySelectorAll('a[href="#recovery-quote"]').forEach(function (a) { a.addEventListener('click', function () { var d = document.getElementById('recovery-quote'); if (d) d.open = true; }); });
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
56: 'Freezing drizzle', 57: 'Freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Freezing rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Violent showers',
85: 'Snow showers', 86: 'Snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with hail'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
if (!LAT || !LNG) return; // 기준 좌표가 없으면(확인 대기) 날씨도 띄우지 않는다
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || 'Current conditions';
document.getElementById('wx-m').textContent =
'Feels like ' + Math.round(c.apparent_temperature) + '°C · wind ' +
Math.round(c.wind_speed_10m) + ' km/h · observed ' + String(c.time).replace('T', ' ');
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? 'Wet outside. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m >= 30
? 'Hot today. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m <= 0
? 'Below freezing. The Culture and Shopping listings below are indoors.'
: 'Dry at the moment. The walking times below are on foot from the clinic.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -0,0 +1,515 @@
---
// 주변 안내(한국어). /en/stay 의 한국어 짝이다. haewon 결정(2026-09-11): 주변 안내(/stay)와 회복 일정 플래너(/plan)는 별개 페이지다. 이 페이지는 병원 주변 장소·축제·동선이고, 시술 후 관리 안내는 접이식으로 둔다. 데이터는 국문 서비스(KorService2)라
// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건).
// 구조는 /en/stay 와 같다. 세 부분이다.
// 1. 병원 주변 : 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals : 계절로 묶은 행사
// 3. Suggested days : 시간표와 지도가 붙은 동선
// + 시술 후 관리 안내(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장
//
// 데이터는 한국관광공사 TourAPI 국문(KorService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourismKo.json 이다. 설명문은 detailCommon2 의 overview 다.
// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소는 factSheet, 기준점은
// medicalTourismKo.meta.origin 에서만 읽는다. 이 파일에 특정 병원을 적지 않는다.
// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "확인 대기"를 그리고 빌드는 그대로 된다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib';
import data from '../data/medicalTourismKo.json';
import recovery from '../data/recoveryNotes.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortName || '병원';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 "확인 대기"로 그린다.
const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string };
const ready = Boolean(origin.lat && origin.lng);
const total: number = T.filters?.total ?? 0;
const festivals: any[] = T.festivals ?? [];
const updated: string = (T.meta?.fetchedAt || '').slice(0, 10);
// 거리의 기준 주소. 수집 때 쓴 주소가 있으면 그것, 없으면 팩트 시트 주소.
const originAddress = origin.address || f.address?.full || '';
const festKm: number = T.meta?.festivalRadiusKm || 0;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: '식사', note: '음식점과 카페' },
{ key: 'stay', label: '숙박', note: '묵을 곳' },
{ key: 'wellness', label: '휴식', note: '스파와 찜질방' },
{ key: 'attraction', label: '관광', note: '거리, 공원, 명소' },
{ key: 'culture', label: '문화', note: '미술관, 공연장' },
{ key: 'shopping', label: '쇼핑', note: '백화점과 시장' },
];
const SEASONS = [
{ key: 'spring', label: '봄' }, { key: 'summer', label: '여름' },
{ key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' },
];
const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record<string, string>)[nowSeasonKey];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
// 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다.
const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${+d.slice(4, 6)}.${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
// travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다.
const travelLabel = (t: { mode: string; minutes: number } | null) =>
!t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`;
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0;
const pick = (key: string, maxMin: number, n: number): Place[] =>
(((T.places ?? {})[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: '진료 다녀오는 날', who: '병원에 자주 오는 동안',
blurb: '전부 걸어서 갈 수 있는 곳입니다. 택시가 필요 없습니다.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, '진료가 끝나는 곳입니다.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: '가까운 곳 나들이', who: '조금 더 걸을 수 있을 때',
blurb: '가깝지만 한 블록 밖입니다.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, '병원에서 출발합니다.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: '멀리 나갈 수 있을 때', who: '체류 후반에',
blurb: '시내 곳곳입니다. 택시나 지하철을 탑니다.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, '병원에서 나섭니다.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다.
// 병원 확정값(site.recoveryStagesKo)과 역할이 다르다. 확정값은 "병원 안내" 문단, 이것은 원문 인용이라 출처 링크와 확인일을 붙인다.
// items 가 비어 있으면(템플릿 기본값) 블록을 아예 그리지 않는다.
type RecoveryItem = { procedure: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean };
const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] };
const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length);
const recoveryDate = RN.generatedAt || '';
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
(((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
title={`${clinic} 주변 안내 · 식사, 숙박, 휴식, 관광, 축제, 하루 동선`}
description={`${clinic} 주변에서 어디서 먹고 묵고 쉴 수 있는지, 무엇을 볼 수 있는지, 어떤 축제가 열리는지를 병원에서의 거리와 도보 시간으로 정리했습니다. 출처는 한국관광공사입니다.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">Nearby</div>
<h1 class="serif">Around The Clinic</h1>
<p class="lede">
<strong>{clinic} 주변입니다.</strong> {ready
? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.`
: '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'}
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에, 수술일 기준 입국·출국 계산은 <a href="/plan">회복 일정</a>에 있습니다.
</p>
{!ready && <p class="pending-note">주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.</p>}
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">불러오는 중</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="섹션으로 이동">
{recoveryItems.length > 0 && <a href="#recovery-quote">시술 후 관리<b>{recoveryItems.length}</b></a>}
{GROUPS.filter((g) => allPlaces.some((p) => p.cat === g.key)).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{allPlaces.filter((p) => p.cat === g.key).length}</b></a>
))}
<a href="#festivals">축제<b>{festivals.length}</b></a>
<a href="#days">동선<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 0. 시술 후 관리 안내 ── 병원 시술 페이지 원문 인용. 기본은 접힘, summary 가 버튼이다(haewon 2026-09-11).
동선 카드(.route)·정차(.stop) 룩을 그대로 쓴다. id="recovery-quote" 는 발행 게이트(check.mjs)가 홈페이지 40자 일치 검사에서 이 인용 블록만 빼는 표식이다. -->
{recoveryItems.length > 0 && (
<details class="route recovery" id="recovery-quote">
<summary class="btn primary"><span class="show">시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개</span><span class="hide">시술 후 관리 안내 접기</span></summary>
<div class="route-head">
<div>
<h3>시술 후 관리 안내</h3>
<p class="who">{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개</p>
</div>
{recoveryDate && <div class="clock">{recoveryDate} 확인</div>}
</div>
<p class="blurb">아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.</p>
<ol class="timeline">
{recoveryItems.map((it, i) => (
<li>
<details open={i < 3}>
<summary>
<span class="stop">
<span class="n">{i + 1}</span>
<span>
<span class="stop-t">{it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기</span>
<h4>{it.procedure}</h4>
</span>
</span>
</summary>
<ul>
{it.ko.map((s) => <li>{s}</li>)}
</ul>
<p><a class="go" href={it.url} rel="noopener nofollow">병원 페이지 보기 ↗</a>{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}</p>
</details>
</li>
))}
</ol>
</details>
)}
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>병원 주변</h2>
<span class="upd">{updated ? `${updated} 갱신` : '확인 대기'}</span>
</div>
<p class="sub">{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}</p>
{total > 0 && <div class="chips" id="walkchips" role="group" aria-label="이동 시간으로 거르기">
<button class="chip on" data-max="all">전체 <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">도보 5분 이내 <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">도보 15분 이내 <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">걸어갈 수 있는 곳 <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">차로 가는 곳 <b>{T.filters.drive}</b></button>
</div>}
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title}, ${clinic} 주변 ${g.label}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{travelLabel(p.travel)} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">공식 홈페이지 ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>계절별 축제</h2></div>
<p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}</p>
<p class="desc">{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>
</div>
</article>
))}
</div>
<p class="src">출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>하루 동선</h2></div>
<p class="sub">
위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다.
</p>
{Sx.recoveryStagesKo
? <p><strong>{clinic} 안내.</strong> {Sx.recoveryStagesKo}</p>
: <p class="pending-note">어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`동선 지도, 정차 ${pins.pins.length}곳: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin}분</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>이 페이지가 답하지 못하는 것</h2>
<ul>
<li>평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.</li>
<li>어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.</li>
<li>{total > 0
? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.`
: '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}</li>
<li>여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.</li>
</ul>
<p class="botnote">
{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: origin.lat ?? 0, WX_LNG: origin.lng ?? 0 }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 점프 칩 '시술 후 관리'는 접힌 블록으로 가므로 눌렀을 때 함께 연다.
document.querySelectorAll('a[href="#recovery-quote"]').forEach(function (a) { a.addEventListener('click', function () { var d = document.getElementById('recovery-quote'); if (d) d.open = true; }); });
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: '맑음', 1: '대체로 맑음', 2: '구름 조금', 3: '흐림',
45: '안개', 48: '서리 안개', 51: '약한 이슬비', 53: '이슬비', 55: '강한 이슬비',
56: '언 이슬비', 57: '언 이슬비', 61: '약한 비', 63: '비', 65: '강한 비',
66: '언 비', 67: '언 비', 71: '약한 눈', 73: '눈', 75: '강한 눈',
77: '싸락눈', 80: '약한 소나기', 81: '소나기', 82: '강한 소나기',
85: '진눈깨비', 86: '진눈깨비', 95: '뇌우', 96: '우박을 동반한 뇌우', 99: '우박을 동반한 뇌우'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
if (!LAT || !LNG) return; // 기준 좌표가 없으면(확인 대기) 날씨도 띄우지 않는다
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || '현재 날씨';
document.getElementById('wx-m').textContent =
'체감 ' + Math.round(c.apparent_temperature) + '°C · 바람 ' +
Math.round(c.wind_speed_10m) + ' km/h · ' + String(c.time).replace('T', ' ') + ' 관측';
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? '비가 옵니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m >= 30
? '더운 날입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m <= 0
? '영하입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: '지금은 비가 오지 않습니다. 아래 시간은 병원에서 걸어가는 기준입니다.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -28,20 +28,10 @@
"destination": "/plan", "destination": "/plan",
"permanent": true "permanent": true
}, },
{
"source": "/stay",
"destination": "/plan",
"permanent": true
},
{ {
"source": "/en/recovery", "source": "/en/recovery",
"destination": "/en/plan", "destination": "/en/plan",
"permanent": true "permanent": true
},
{
"source": "/en/stay",
"destination": "/en/plan",
"permanent": true
} }
] ]
} }

View File

@ -26,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url');
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); } if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic))); const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done']; const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'publish', 'done'];
const startFrom = opt('from', 'evidence'); const startFrom = opt('from', 'evidence');
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })(); (function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })();
@ -112,6 +112,13 @@ await phase('data', () => {
if (existsSync(join(KEEP, 'posts'))) cpSync(join(KEEP, 'posts'), POSTS, { recursive: true }); if (existsSync(join(KEEP, 'posts'))) cpSync(join(KEEP, 'posts'), POSTS, { recursive: true });
if (existsSync(join(KEEP, 'videos.json'))) cpSync(join(KEEP, 'videos.json'), join(DATA, 'videos.json')); if (existsSync(join(KEEP, 'videos.json'))) cpSync(join(KEEP, 'videos.json'), join(DATA, 'videos.json'));
for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); } for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); }
// 색인 허용 빌드(--indexable): noindex 헤더 제거 + PUBLIC_INDEXABLE=true(승인된 글만 발행). 정식 도메인(--domain)이 있으면 SITE_URL 을 그 주소로.
const liveUrl = opt('domain') ? `https://${opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '')}` : `https://supporters-${clinic}.vercel.app`;
{ const vp = join(SITE, 'vercel.json'); const vj = readJson(vp); vj.build = vj.build ?? {}; vj.build.env = { ...(vj.build.env ?? {}), SITE_URL: liveUrl, PUBLIC_INDEXABLE: flag('indexable') ? 'true' : 'false' };
if (flag('indexable')) vj.headers = (vj.headers ?? []).filter((h) => !(h.headers ?? []).some((x) => x.key === 'X-Robots-Tag')); writeJson(vp, vj); }
// IndexNow 키: 사이트마다 하나. public/<key>.txt 로 두면 빙·네이버가 소유를 확인한다. 검색엔진 소유 확인 메타 값은 briefs/<clinic>/clinic.json 의 site 로 들어온다(inputs 단계).
{ const sp = join(DATA, 'site.json'); const sj = readJson(sp); if (!sj.indexNowKey) { sj.indexNowKey = [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); writeJson(sp, sj); }
mkdirSync(join(SITE, 'public'), { recursive: true }); writeFileSync(join(SITE, 'public', `${sj.indexNowKey}.txt`), sj.indexNowKey); }
cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt')); cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt'));
// 팩트 시트: 후보 중 첫 값. 사람이 대조하기 전까지 "확인 대기"가 붙는 필드가 많다(v2 §4-5). // 팩트 시트: 후보 중 첫 값. 사람이 대조하기 전까지 "확인 대기"가 붙는 필드가 많다(v2 §4-5).
@ -429,12 +436,30 @@ await phase('deploy', () => {
const project = `supporters-${clinic}`; const project = `supporters-${clinic}`;
const link = run('vercel', ['link', '--yes', '--project', project], { cwd: SITE }); const link = run('vercel', ['link', '--yes', '--project', project], { cwd: SITE });
if (!link.ok) throw new Error(`vercel link 실패\n${tail(link.out)}`); if (!link.ok) throw new Error(`vercel link 실패\n${tail(link.out)}`);
const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: `https://${project}.vercel.app` } }); const domain = opt('domain') ? opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '') : '';
if (domain) { const d = run('vercel', ['domains', 'add', domain, project, '--yes'], { cwd: SITE }); status.report = { ...(status.report ?? {}), domain: d.ok ? `연결 요청됨 ${domain}` : `도메인 추가 실패(DNS·소유 확인 필요)\n${tail(d.out, 3)}` }; }
const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: domain ? `https://${domain}` : `https://${project}.vercel.app` } });
const m = dep.out.match(/https:\/\/[^\s]+\.vercel\.app/g); const m = dep.out.match(/https:\/\/[^\s]+\.vercel\.app/g);
if (!dep.ok || !m) throw new Error(`vercel deploy 실패\n${tail(dep.out)}`); if (!dep.ok || !m) throw new Error(`vercel deploy 실패\n${tail(dep.out)}`);
status.previewUrl = `https://${project}.vercel.app`; status.previewUrl = `https://${project}.vercel.app`;
return status.previewUrl; status.liveUrl = domain ? `https://${domain}` : status.previewUrl;
return status.liveUrl;
}); });
await phase('done', async () => { await setStatus({ status: 'preview' }); return `status.json → ${statusPath}`; }); // 8. 발행(색인) 단계: 배포가 끝이 아니다. 라이브 확인 → IndexNow(빙·네이버) → Google Search Console(서비스 계정 있을 때) → 사람 체크리스트(네이버 등록·GSC 소유 확인·도메인·법무). haewon 결정 2026-09-11.
console.log(`\n완료: ${status.posts.length}편 · ${status.previewUrl ?? '미배포'} · ${WORK}`); await phase('publish', () => {
if (!status.previewUrl) return '건너뜀 (미배포)';
const argv = ['scripts/publish_site.mjs', '--clinic', clinic, '--site', SITE, '--url', status.liveUrl || status.previewUrl, '--work', WORK];
if (flag('indexable')) argv.push('--indexable');
const r = run('node', argv);
const m = r.out.match(/__PUBLISH_JSON__(.+)$/m);
const pub = m ? JSON.parse(m[1]) : null;
if (!r.ok || !pub) return { warn: true, summary: `발행 단계 실패 (체크리스트 없이 종료)\n${tail(r.out, 4)}` };
status.report = { ...(status.report ?? {}), publish: pub };
status.humanTasks = pub.humanTasks;
const n = pub.humanTasks.length;
return `색인 허용 ${pub.indexable ? '예' : '아니오'} · IndexNow ${pub.indexnow?.status ?? pub.indexnow?.skipped ?? '-'} · GSC ${pub.google?.sitemapSubmit ?? pub.google?.skipped ?? '-'} · 사람이 할 일 ${n}건 → ${join(WORK, 'publish-checklist.md')}`;
});
await phase('done', async () => { await setStatus({ status: flag('indexable') ? (status.humanTasks?.length ? 'published_pending_tasks' : 'published') : 'preview' }); return `status.json → ${statusPath}`; });
console.log(`\n완료: ${status.posts.length}편 · ${status.liveUrl ?? status.previewUrl ?? '미배포'} · ${WORK}${status.humanTasks?.length ? `\n사람이 할 일 ${status.humanTasks.length}건 (publish-checklist.md):\n - ${status.humanTasks.join('\n - ')}` : ''}`);