feat(supporters): 템플릿화 — 병원 고유값을 데이터로 분리하고 templates/supporters-astro 내보내기

- src/data/site.json 신설(매체 이름·로고·홈 큐레이션), factSheet에 kind·areaLabel·transitShort·transitNote·reservationUrl·doctorsUrl·youtube.title 추가
- lib.ts: PENDING('확인 대기')·v()·has()·surface()·editor() 도입, 스키마는 빈 값을 빼고 생성
- 페이지·컴포넌트 15개에서 뷰성형외과 하드코딩 제거, 빈 데이터 empty-state (글 0편·영상 0건·뉴스 0건·이미지 없음)
- 검증: 28페이지 표시 텍스트·구조 지문 이전 빌드와 동일(홈 title 마침표 1건만 정정), JSON-LD 차이는 빈 sameAs 제거뿐
- scripts/export_template.mjs + scripts/template/data(빈 데이터 세트) → templates/supporters-astro (빈 데이터로 10페이지 0오류 빌드, 게이트 통과)
- 게이트 픽스처를 자체 authors로 분리해 템플릿에서도 테스트 통과, 구조 기준선의 글 상세 페이지는 사전순 첫 글로 동적 선택

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-07 14:07:14 +09:00
parent 49c5e46f23
commit 622441e6aa
100 changed files with 6886 additions and 174 deletions

View File

@ -11,7 +11,8 @@
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"check": "node scripts/gate/test.mjs && node scripts/check.mjs", "check": "node scripts/gate/test.mjs && node scripts/check.mjs",
"gate:test": "node scripts/gate/test.mjs" "gate:test": "node scripts/gate/test.mjs",
"export:template": "node scripts/export_template.mjs"
}, },
"dependencies": { "dependencies": {
"astro": "^7.3.1" "astro": "^7.3.1"

View File

@ -23,7 +23,8 @@ const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8')
const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8')); const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8'));
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교. // 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set', '/posts/anesthesia-choice.html': 'sequence' }; // 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙).
const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set' };
function walk(dir, out = []) { function walk(dir, out = []) {
for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); } for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); }
@ -36,6 +37,8 @@ const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }))
// ---------- 1. dist 검사 ---------- // ---------- 1. dist 검사 ----------
if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); } if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); }
const files = walk(DIST); const files = walk(DIST);
const firstPost = files.map((f) => f.replace(DIST, '/')).filter((r) => r.startsWith('/posts/')).sort()[0];
if (firstPost) LAYOUT_PAGES[firstPost] = 'sequence';
const layoutNow = {}; const layoutNow = {};
for (const f of files) { for (const f of files) {
const html = readFileSync(f, 'utf8'); const html = readFileSync(f, 'utf8');

View File

@ -0,0 +1,52 @@
// supporters/(뷰성형외과 샘플) → templates/supporters-astro/ 내보내기.
// 병원 고유 데이터(글·데이터 JSON·이미지·home_text·구조 기준선)를 빼고 빈 데이터 세트(scripts/template/data)를 넣는다.
// 멱등: 대상 폴더를 지우고 다시 만든다.
// node scripts/export_template.mjs [--dest ../templates/supporters-astro]
import { cpSync, rmSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const SRC = fileURLToPath(new URL('../', import.meta.url)).replace(/\/$/, '');
const argDest = process.argv.indexOf('--dest');
const DEST = argDest > -1 ? process.argv[argDest + 1] : join(SRC, '..', 'templates', 'supporters-astro');
const TPL = join(SRC, 'scripts', 'template');
// 복사에서 뺄 경로 (SRC 기준 상대). 디렉터리는 하위 전부.
const EXCLUDE = [
'node_modules', 'dist', '.astro', '.vercel', 'docs',
'src/content/posts', 'public/img', 'scripts/home_text.txt', 'scripts/gate/layout-baseline.json', 'scripts/template', 'README.md',
];
const excluded = (abs) => {
const rel = relative(SRC, abs).split(sep).join('/');
return EXCLUDE.some((e) => rel === e || rel.startsWith(e + '/'));
};
rmSync(DEST, { recursive: true, force: true });
mkdirSync(DEST, { recursive: true });
cpSync(SRC, DEST, { recursive: true, filter: (s) => !excluded(s) });
// 빈 데이터 세트
for (const f of readdirSync(join(TPL, 'data'))) cpSync(join(TPL, 'data', f), join(DEST, 'src', 'data', f));
mkdirSync(join(DEST, 'src', 'content', 'posts'), { recursive: true });
writeFileSync(join(DEST, 'src', 'content', 'posts', '.gitkeep'), '');
mkdirSync(join(DEST, 'public', 'img'), { recursive: true });
writeFileSync(join(DEST, 'public', 'img', '.gitkeep'), '');
writeFileSync(join(DEST, 'scripts', 'home_text.txt'), '');
cpSync(join(TPL, 'README.template.md'), join(DEST, 'README.md'));
// 배포 주소 자리표시자
const sub = (rel, from, to) => { const p = join(DEST, rel); const s = readFileSync(p, 'utf8'); if (!s.includes(from)) throw new Error(`${rel}: "${from}" 없음`); writeFileSync(p, s.split(from).join(to)); };
sub('astro.config.mjs', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app');
sub('vercel.json', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app');
const pkgPath = join(DEST, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
pkg.name = 'supporters-template';
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
// 남은 병원 고유 문자열 검사
const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/;
const leaks = [];
const walk = (d) => { for (const f of readdirSync(d, { withFileTypes: true })) { const p = join(d, f.name); if (f.isDirectory()) { if (f.name !== 'node_modules') walk(p); } else if (/\.(astro|ts|mjs|json|css)$/.test(f.name) && !p.endsWith('export_template.mjs') && !p.includes('/scripts/gate/fixtures/') && CLINIC_RE.test(readFileSync(p, 'utf8'))) leaks.push(relative(DEST, p)); } };
walk(DEST);
if (leaks.length) { console.error('병원 고유 문자열이 남은 파일:\n ' + leaks.join('\n ')); process.exit(1); }
console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`);

View File

@ -0,0 +1,5 @@
{
"_comment": "픽스처 전용 작성자·검토자. 사이트의 authors.json과 무관하게 테스트가 돌도록 한다.",
"supporters": { "ansm": { "name": "픽스처 편집자", "role": "편집 책임", "email": "", "sameAs": [] } },
"physicians": { "dr-choi": { "name": "픽스처 원장", "title": "원장 · 성형외과 전문의", "credentials": [], "url": "https://example.com/dr", "sameAs": [] } }
}

View File

@ -13,7 +13,9 @@ const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const FIX = here('./fixtures/'); const FIX = here('./fixtures/');
const POSTS = here('../../src/content/posts/'); const POSTS = here('../../src/content/posts/');
const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8')); const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8'));
const ctx = { physicians: authors.physicians, supporters: authors.supporters }; const ctx = { physicians: authors.physicians, supporters: authors.supporters }; // 실제 글 회귀용
const fx = JSON.parse(readFileSync(join(FIX, '_authors.json'), 'utf8'));
const fxCtx = { physicians: fx.physicians, supporters: fx.supporters }; // 픽스처용
const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8')); const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8'));
let pass = 0, failCount = 0; let pass = 0, failCount = 0;
@ -26,7 +28,7 @@ for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8')); const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
const { expect = [], ...fm } = data; const { expect = [], ...fm } = data;
const merged = { ...base, ...fm }; const merged = { ...base, ...fm };
const got = codes(R.checkPostSource({ data: merged, body }, ctx)); const got = codes(R.checkPostSource({ data: merged, body }, fxCtx));
const missing = expect.filter((c) => !got.includes(c)); const missing = expect.filter((c) => !got.includes(c));
const extra = got.filter((c) => !expect.includes(c)); const extra = got.filter((c) => !expect.includes(c));
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`); if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
@ -51,7 +53,7 @@ for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E); const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E);
if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); } if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); }
} }
if (!postErrors) ok('src/content/posts 전편', '소스 규칙 error 0'); if (!postErrors) ok('src/content/posts 전편', `소스 규칙 error 0 (${readdirSync(POSTS).filter((x) => x.endsWith('.md')).length}편)`);
// 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장) // 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장)
const negatives = [ const negatives = [

View File

@ -0,0 +1,30 @@
# supporters-astro 템플릿
병원 하나의 서포터즈 사이트를 만드는 Astro 7 정적 템플릿이다. `supporters/`(뷰성형외과 샘플)에서 `node scripts/export_template.mjs`로 생성되며, 직접 편집하지 않는다. 고칠 것은 `supporters/`에서 고치고 다시 내보낸다.
## 데이터만 채우면 되는 것
| 파일 | 내용 | 채우는 주체 |
|---|---|---|
| `src/data/factSheet.json` | 병원 사실 단일 원본 (NAP·진료시간·층·진료과·안전·이력·플랫폼 집계) | 근거 수집기 + 병원 확인 |
| `src/data/authors.json` | 작성자(실명·직함·sameAs·정정 이메일), 의학 검토 원장 | 운영자·병원 입력 |
| `src/data/site.json` | 매체 이름·로고·홈 화면 큐레이션(영상 3편, 시설 사진, 방문 약도, 추천 글) | 운영자 |
| `src/data/videos.json` | 유튜브 채널 집계 (Top10 롱폼·정보형 쇼츠+답) | 수집기 |
| `src/data/news.json` | 뉴스룸 (보도자료·기고·언급) | `scripts/build_news.py` |
| `src/data/corrections.json` | 정정 기록 | 운영자 |
| `src/content/posts/*.md` | 글. frontmatter 규격은 `src/content.config.ts` | 글 생성기 + 검토 승인 |
| `public/img/` | 병원 허락을 받은 이미지 | 수집기 |
| `scripts/home_text.txt` | 홈페이지 본문(공백 제거). 40자 연속 일치 검사 기준 | 수집기 |
비어 있는 값은 화면에 "확인 대기"로 표시되고 스키마에서는 빠진다. 지어내지 않는다.
## 빌드·검사
```
npm ci
npm run build # astro build + 발행 게이트(scripts/check.mjs). error가 있으면 exit 1
npm run gate:test # 게이트 규칙 회귀 (픽스처 + 글 전편)
node scripts/check.mjs --update-layout # 룩앤필 변경을 승인한 뒤에만 구조 기준선 갱신
```
배포 주소는 `SITE_URL` 환경변수(vercel.json·astro.config.mjs의 `__CLINIC_ID__`를 병원 id로 교체). 기본은 noindex이며 `PUBLIC_INDEXABLE=true`로 색인을 연다.

View File

@ -0,0 +1,13 @@
{
"_comment": "작성자(supporters)와 의학 검토 원장(physicians). 작성자 실명·직함·sameAs·정정 이메일은 병원/운영자 입력값(v2 §6-1). 검토 원장은 병원이 확정한 사람만 넣는다(§3-7).",
"supporters": {
"editor": {
"name": "편집 책임자 (확인 대기)",
"role": "서포터즈 편집 책임",
"bio": "병원을 알아보는 분들이 실제로 묻는 질문을 고르고, 원장 설명 영상과 병원 공개 자료를 근거로 글을 쓰며 편집 책임을 집니다. 의학적 판단은 하지 않으며, 의학 내용은 병원 담당 원장의 검토를 거쳐 표시합니다.",
"email": "",
"sameAs": []
}
},
"physicians": {}
}

View File

@ -0,0 +1,6 @@
{
"_comment": "정정 기록. 접수·처리한 정정을 날짜순으로 남긴다. contact·owner는 병원/운영자 입력값.",
"contact": "",
"owner": "",
"items": []
}

View File

@ -0,0 +1,32 @@
{
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
"name": "",
"shortName": "",
"kind": "",
"areaLabel": "",
"founded": "",
"representative": "",
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" },
"phone": "",
"fax": "",
"kakao": "",
"url": "",
"urlEn": "",
"reservationUrl": "",
"doctorsUrl": "",
"transit": "",
"transitShort": "",
"transitNote": "",
"parking": "",
"hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] },
"building": { "summary": "", "floors": [], "source": "" },
"departments": [],
"specialties": [],
"safety": { "items": [], "source": "" },
"recognitions": [],
"surfaces": {},
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
"businessNo": "",
"checkedAt": "",
"nextCheck": ""
}

View File

@ -0,0 +1,6 @@
{
"_comment": "뉴스룸 데이터. scripts/build_news.py 결과. 병원 배포 기사·원장 기고만, 제3자 규제·사건 보도 제외.",
"generatedAt": null,
"sources": [],
"items": []
}

View File

@ -0,0 +1,24 @@
{
"_comment": "매체 고유값. 비어 있으면(null·[]) 해당 블록은 '확인 대기' 문장으로 렌더된다. 이미지는 병원 허락을 받은 것만 public/img/ 에 넣고 여기서 참조한다.",
"siteName": "",
"siteNameEn": "SUPPORTERS",
"eyebrow": "",
"tagline": "",
"heroTitle": "궁금한 것부터 답합니다.",
"editorId": "editor",
"logo": null,
"logoFoot": null,
"heroImage": null,
"buildingImage": null,
"imageCredit": "",
"homeVideos": [],
"insideSummary": "",
"insideGallery": [],
"clinicGallery": [],
"clinicDoctors": [],
"visitMaps": [],
"arrivalGuide": "",
"visitReads": [],
"newsNote": "",
"newsOutletsExample": ""
}

View File

@ -0,0 +1,10 @@
{
"_comment": "유튜브 채널 집계. uploads 전량 → videos.list → 조회수 순위. 쇼츠 판별은 /shorts/{id} 200 응답. topLong·shortsInfo는 정보형만(제목 금칙 필터 통과).",
"fetchedAt": null,
"channel": {},
"top": [],
"topLong": [],
"topShorts": [],
"shortsInfo": [],
"shortsDetection": ""
}

View File

@ -1,6 +1,6 @@
--- ---
import { fact } from '../lib'; import { fact, SITE_NAME, v } from '../lib';
--- ---
<p class="disclosure"> <p class="disclosure">
뷰 서포터즈 활동은 {fact.shortName}의 지원을 받습니다. 이 글에는 다른 환자의 치료 경험담과 전후 사진을 싣지 않습니다. {fact.sideEffectNotice} {SITE_NAME} 활동은 {v(fact.shortName, '병원')}의 지원을 받습니다. 이 글에는 다른 환자의 치료 경험담과 전후 사진을 싣지 않습니다. {fact.sideEffectNotice}
</p> </p>

View File

@ -1,17 +1,19 @@
--- ---
import { fact } from '../lib'; import { fact, v, has } from '../lib';
const f = fact as Record<string, any>;
const rows = f.hours?.rows ?? [];
interface Props { compact?: boolean } interface Props { compact?: boolean }
const { compact = false } = Astro.props; const { compact = false } = Astro.props;
--- ---
<aside class="fact-block" aria-label="병원 기본 정보"> <aside class="fact-block" aria-label="병원 기본 정보">
<h2>{fact.shortName} 기본 정보</h2> <h2>{v(f.shortName, '병원')} 기본 정보</h2>
<dl> <dl>
<dt>정식 명칭</dt><dd>{fact.name}</dd> <dt>정식 명칭</dt><dd>{v(f.name)}</dd>
<dt>주소</dt><dd>{fact.address.full}</dd> <dt>주소</dt><dd>{v(f.address?.full)}</dd>
<dt>전화</dt><dd><a href={`tel:${fact.phone.replace(/-/g, '')}`}>{fact.phone}</a></dd> <dt>전화</dt><dd>{has(f.phone) ? <a href={`tel:${f.phone.replace(/-/g, '')}`}>{f.phone}</a> : v(f.phone)}</dd>
<dt>진료시간</dt><dd>{fact.hours.rows.map((r) => r.open ? `${r.days} ${r.open}~${r.close}` : `${r.days} ${r.note}`).join(' · ')}</dd> <dt>진료시간</dt><dd>{rows.length ? rows.map((r: any) => r.open ? `${r.days} ${r.open}~${r.close}` : `${r.days} ${r.note}`).join(' · ') : v(null)}</dd>
<dt>가는 길</dt><dd>{fact.transit}</dd> <dt>가는 길</dt><dd>{v(f.transit)}</dd>
{!compact && <><dt>개원</dt><dd>{fact.founded}년</dd><dt>규모</dt><dd>{fact.building.summary}</dd></>} {!compact && <><dt>개원</dt><dd>{has(f.founded) ? `${f.founded}년` : v(null)}</dd><dt>규모</dt><dd>{v(f.building?.summary)}</dd></>}
</dl> </dl>
<p style="margin:0.8rem 0 0;font-size:0.8rem;color:var(--slate-500)">진료시간 출처: <a href={fact.hours.source} rel="noopener">{fact.hours.sourceLabel}</a>. 주소·전화는 <a href={fact.address.source} rel="noopener">공식 홈페이지 표기</a>를 따릅니다. 이 정보는 {fact.checkedAt}에 확인했고 다음 확인 예정일은 {fact.nextCheck}입니다.</p> <p style="margin:0.8rem 0 0;font-size:0.8rem;color:var(--slate-500)">진료시간 출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 주소·전화는 {has(f.address?.source) ? <a href={f.address.source} rel="noopener">공식 홈페이지 표기</a> : '공식 홈페이지 표기'}를 따릅니다. 이 정보는 {v(f.checkedAt, '확인일 미정')}에 확인했고 다음 확인 예정일은 {v(f.nextCheck, '미정')}입니다.</p>
</aside> </aside>

View File

@ -1,11 +1,11 @@
--- ---
import { CATEGORY_LABELS, supporter } from '../lib'; import { CATEGORY_LABELS, supporter, site as S, fact } from '../lib';
interface Props { post: any } interface Props { post: any }
const { post } = Astro.props; const { post } = Astro.props;
const d = post.data; const d = post.data;
const v0 = d.videos?.[0] ?? d.video; const v0 = d.videos?.[0] ?? d.video;
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : '/img/clinic/building-clean.jpg'); const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? '/favicon.svg');
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : '뷰성형외과 빌딩 외관'); const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? `${fact.shortName} 건물 외관`);
--- ---
<article class="card has-thumb"> <article class="card has-thumb">
<a class="thumb" href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a> <a class="thumb" href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>

View File

@ -8,7 +8,7 @@ const p = reviewer ? physician(reviewer) : null;
<section class="review-box" aria-label="작성·감수 정보"> <section class="review-box" aria-label="작성·감수 정보">
{p?.image ? <img src={p.image} alt={`${p.name} ${p.title}`} width="56" height="56" /> : <div class="avatar">{a.name.slice(0, 1)}</div>} {p?.image ? <img src={p.image} alt={`${p.name} ${p.title}`} width="56" height="56" /> : <div class="avatar">{a.name.slice(0, 1)}</div>}
<div> <div>
<div><strong>작성</strong> <a href={`/authors/${author}`}>{a.name}</a> · {a.role} · 정정 요청 <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></div> <div><strong>작성</strong> <a href={`/authors/${author}`}>{a.name}</a> · {a.role} {a.email ? <> · 정정 요청 <a href={`mailto:${a.email}`}>{a.email}</a></> : <> · 정정 요청은 <a href="/corrections">정정 기록</a> 페이지 안내를 따릅니다</>}</div>
{p && status === 'reviewed' ? ( {p && status === 'reviewed' ? (
<div><strong>의학 검토</strong> {p.name} {p.title} <span class="badge reviewed">검토 완료 {dateModified}</span></div> <div><strong>의학 검토</strong> {p.name} {p.title} <span class="badge reviewed">검토 완료 {dateModified}</span></div>
) : ( ) : (

View File

@ -1,6 +1,8 @@
--- ---
// 유튜브 라이트 임베드: 썸네일(i.ytimg.com) + 재생 버튼. 클릭 시 iframe으로 교체. 헤드리스·저속 환경에서도 시각적으로 보인다. // 유튜브 라이트 임베드: 썸네일(i.ytimg.com) + 재생 버튼. 클릭 시 iframe으로 교체. 헤드리스·저속 환경에서도 시각적으로 보인다.
import { surface } from '../lib';
interface Props { id: string; title: string; speaker?: string; published?: string; note?: string; compact?: boolean } interface Props { id: string; title: string; speaker?: string; published?: string; note?: string; compact?: boolean }
const handle = surface('youtube').handle ? `유튜브 ${surface('youtube').handle}` : '유튜브';
const { id, title, speaker, published, note, compact = false } = Astro.props; const { id, title, speaker, published, note, compact = false } = Astro.props;
--- ---
<figure class={`yt ${compact ? 'compact' : ''}`}> <figure class={`yt ${compact ? 'compact' : ''}`}>
@ -10,7 +12,7 @@ const { id, title, speaker, published, note, compact = false } = Astro.props;
</button> </button>
<figcaption> <figcaption>
<strong>{title}</strong> <strong>{title}</strong>
<span>{speaker ? `${speaker} · ` : ''}{published ? `${published} 게시 · ` : ''}유튜브 @ViewclinicKR{note ? ` · ${note}` : ''}</span> <span>{speaker ? `${speaker} · ` : ''}{published ? `${published} 게시 · ` : ''}{handle}{note ? ` · ${note}` : ''}</span>
</figcaption> </figcaption>
</figure> </figure>
<script> <script>

View File

@ -2,6 +2,8 @@
"_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.", "_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.",
"name": "뷰성형외과의원", "name": "뷰성형외과의원",
"shortName": "뷰성형외과", "shortName": "뷰성형외과",
"kind": "성형외과",
"areaLabel": "서울 강남 신논현역",
"founded": "2005", "founded": "2005",
"representative": "최순우", "representative": "최순우",
"address": { "address": {
@ -18,7 +20,11 @@
"kakao": "https://pf.kakao.com/_xbtVxjl", "kakao": "https://pf.kakao.com/_xbtVxjl",
"url": "https://www.viewclinic.com", "url": "https://www.viewclinic.com",
"urlEn": "https://www.viewplasticsurgery.com", "urlEn": "https://www.viewplasticsurgery.com",
"reservationUrl": "https://www.viewclinic.com/counsel/reservation/",
"doctorsUrl": "https://www.viewclinic.com/viewis/doctors/",
"transit": "지하철 9호선 신논현역 3번 출구에서 50m 이내", "transit": "지하철 9호선 신논현역 3번 출구에서 50m 이내",
"transitShort": "신논현역 3번 출구 50m",
"transitNote": "건물 전체가 병원이라 출구에서 바로 보입니다.",
"parking": "병원 정문 쪽 도로는 일방통행이라 진입 불가. 약국 골목으로 진입 후 사거리 골목에서 우회전, 좌측에 주차장. 네비게이션은 봉은사로 1길 4로 설정", "parking": "병원 정문 쪽 도로는 일방통행이라 진입 불가. 약국 골목으로 진입 후 사거리 골목에서 우회전, 좌측에 주차장. 네비게이션은 봉은사로 1길 4로 설정",
"hours": { "hours": {
"source": "https://www.gangnamunni.com/hospitals/189", "source": "https://www.gangnamunni.com/hospitals/189",
@ -186,6 +192,7 @@
"checked": "2026-04 (INFINITH 리포트 기준)" "checked": "2026-04 (INFINITH 리포트 기준)"
}, },
"youtube": { "youtube": {
"title": "뷰성형외과 VIEW Plastic Surgery",
"url": "https://www.youtube.com/@ViewclinicKR", "url": "https://www.youtube.com/@ViewclinicKR",
"handle": "@ViewclinicKR", "handle": "@ViewclinicKR",
"subscribers": "10.8만", "subscribers": "10.8만",

View File

@ -0,0 +1,49 @@
{
"_comment": "사이트(매체) 고유값. 병원 사실은 factSheet.json, 사람은 authors.json, 이 파일은 매체 이름·로고·홈 화면 구성·페이지별 큐레이션만 담는다. 비어 있어도(null·[]) 빌드는 되고 '확인 대기'로 표시된다.",
"siteName": "뷰 서포터즈",
"siteNameEn": "VIEW SUPPORTERS",
"eyebrow": "View Supporters · Sample",
"tagline": "뷰성형외과를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체 (샘플)",
"heroTitle": "궁금한 것부터 답합니다.",
"editorId": "ansm",
"logo": { "src": "/img/view-logo.png", "alt": "뷰성형외과 View Plastic Surgery", "width": 184, "height": 66 },
"logoFoot": { "src": "/img/view-logo-foot.png", "alt": "뷰성형외과", "width": 138, "height": 49 },
"heroImage": { "src": "/img/clinic/hero-collage.jpg", "alt": "뷰성형외과 빌딩 외관과 내부 공간 콜라주. 상담센터, 수술실, 수술센터 복도", "width": 1000, "height": 850 },
"buildingImage": { "src": "/img/clinic/building-clean.jpg", "alt": "뷰성형외과 빌딩 외관. 유리 외벽에 VIEW 로고가 있는 고층 건물", "caption": "뷰성형외과 빌딩 외관. 출처: 공식 홈페이지 병원둘러보기" },
"imageCredit": "사진 출처: 뷰성형외과 공식 홈페이지 병원둘러보기",
"homeVideos": [
{ "id": "Nbemu6mL_uY", "title": "가장 VIEW다운 가치 : 안전과 신뢰의 약속", "published": "2026-05-19" },
{ "id": "_OZRSJIDWgs", "title": "스포르자가 또 방문한 이유, 모티바 프리저베 교육 현장", "published": "2026-08-20" },
{ "id": "xohC5C6aY70", "title": "모티바 프리저베는 수술 직후 만세가 가능하다?", "published": "2026-08-31" }
],
"insideSummary": "19층 자체 사옥. 상담 4~5층, 검진 지하 1층, 수술 11~14층, 입원 8·10층.",
"insideGallery": [
{ "src": "/img/clinic/consult-1.jpg", "alt": "뷰성형외과 4~5층 상담센터 대기 공간. 검은 소파와 밝은 조명", "caption": "4~5F 상담센터" },
{ "src": "/img/clinic/exam-1.jpg", "alt": "뷰성형외과 지하 1층 검진센터 입구. Examination Center 간판", "caption": "B1F 검진센터" },
{ "src": "/img/clinic/surgery-2.jpg", "alt": "뷰성형외과 수술실 내부. 수술대와 무영등", "caption": "11~14F 수술센터" },
{ "src": "/img/clinic/ward-1.jpg", "alt": "뷰성형외과 입원실. 창가의 1인 침대", "caption": "8·10F 입원센터" },
{ "src": "/img/clinic/vip-lounge.jpg", "alt": "뷰성형외과 VIP 라운지. 어두운 톤의 라운지 공간", "caption": "VIP 라운지" },
{ "src": "/img/clinic/dental-15f.jpg", "alt": "뷰성형외과 15층 치과 진료실. 파란 치과 의자가 줄지어 있음", "caption": "15F 치과 (양악·윤곽 협진)" }
],
"clinicGallery": [
{ "src": "/img/clinic/consult-2.jpg", "alt": "상담센터 상담실 내부", "caption": "4~5F 상담센터" },
{ "src": "/img/clinic/exam-2.jpg", "alt": "검진센터 접수 데스크", "caption": "B1F 검진센터" },
{ "src": "/img/clinic/surgery-3.jpg", "alt": "수술실 앞 복도의 수술 실명제 안내 화면", "caption": "11~14F 수술센터 복도" },
{ "src": "/img/clinic/ward-2.jpg", "alt": "입원실 침대", "caption": "8·10F 입원센터" },
{ "src": "/img/clinic/vip-1.jpg", "alt": "VIP 병동 복도", "caption": "7F VIP 입원실" },
{ "src": "/img/clinic/consult-3.jpg", "alt": "상담 대기 라운지", "caption": "상담 대기 라운지" }
],
"clinicDoctors": ["dr-choi", "dr-chung", "dr-son", "dr-oh"],
"visitMaps": [
{ "src": "/img/clinic/map-1.jpg", "alt": "양재·강남역 방향에서 뷰성형외과로 오는 약도. 9호선 신논현역과 병원 위치 표시", "caption": "양재·강남역 방향 약도. 출처: 홈페이지 오시는길" },
{ "src": "/img/clinic/parking-map.jpg", "alt": "뷰성형외과 주차장 진입 경로 약도. 약국 골목으로 진입해 사거리에서 우회전", "caption": "주차장 진입 경로. 출처: 홈페이지 오시는길" }
],
"arrivalGuide": "먼저 1층 안내데스크로 가면 안내해 줍니다. 상담센터는 4~5층, VIP 상담실은 3층, 수술 전 검진은 지하 1층 검진센터입니다.",
"visitReads": [
{ "href": "/posts/visit-guide", "title": "방문 가이드: 위치·진료시간·주차·예약", "note": "대중교통·공항에서 오는 법까지 자세히" },
{ "href": "/posts/safety-system", "title": "뷰성형외과 안전 시스템은 무엇으로 이루어져 있나요?", "note": "상담에서 확인할 네 가지" },
{ "href": "/posts/revision-checklist", "title": "가슴 재수술을 고민 중이라면 무엇을 먼저 확인해야 하나요?", "note": "재수술 상담 준비 체크리스트" }
],
"newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.",
"newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등"
}

View File

@ -1,6 +1,8 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact } from '../lib'; import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib';
const ed = editor();
const f = fact as Record<string, any>;
interface Props { interface Props {
title: string; title: string;
@ -10,7 +12,7 @@ interface Props {
type?: 'website' | 'article'; type?: 'website' | 'article';
noindex?: boolean; noindex?: boolean;
} }
const { title, description, jsonLd, ogImage = '/img/clinic/building-clean.jpg', type = 'website', noindex = false } = Astro.props; const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props;
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
@ -42,7 +44,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>} {!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>}
<header class="site-header"> <header class="site-header">
<div class="wrap-wide bar"> <div class="wrap-wide bar">
<a class="brand" href="/"><img class="brand-logo" src="/img/view-logo.png" alt="뷰성형외과 View Plastic Surgery" width="184" height="66" /><span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>VIEW SUPPORTERS</small></span></a> <a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
<div class="right"> <div class="right">
<nav class="nav"> <nav class="nav">
<a href="/posts">상담 전 질문</a> <a href="/posts">상담 전 질문</a>
@ -51,7 +53,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<a href="/visit">방문 안내</a> <a href="/visit">방문 안내</a>
<a href="/about">매체 소개</a> <a href="/about">매체 소개</a>
</nav> </nav>
<a class="cta" href={fact.url + '/counsel/reservation/'} rel="noopener">상담 예약</a> <a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a>
</div> </div>
</div> </div>
</header> </header>
@ -76,15 +78,15 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<div class="wrap-wide cols"> <div class="wrap-wide cols">
<div> <div>
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p> <p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {fact.shortName}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {fact.shortName} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 안성민.</p> <p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p>
<p>{fact.sideEffectNotice}</p> <p>{fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href="mailto:o2oteam@o2o.kr">정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p> <p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p>
</div> </div>
<div> <div>
<p><img src="/img/view-logo-foot.png" alt="뷰성형외과" width="138" height="49" style="width:138px;height:auto;margin-bottom:0.5rem" /></p> {has(S.logoFoot?.src) && <p><img src={S.logoFoot.src} alt={S.logoFoot.alt} width={S.logoFoot.width} height={S.logoFoot.height} style="width:138px;height:auto;margin-bottom:0.5rem" /></p>}
<p><strong>{fact.name}</strong></p> <p><strong>{v(f.name)}</strong></p>
<p>{fact.address.full}<br />전화 {fact.phone} · 팩스 {fact.fax}<br />대표 {fact.representative} · 사업자등록번호 {fact.businessNo}</p> <p>{v(f.address?.full)}<br />전화 {v(f.phone)} · 팩스 {v(f.fax)}<br />대표 {v(f.representative)} · 사업자등록번호 {v(f.businessNo)}</p>
<p><a href={fact.url} rel="noopener">공식 홈페이지</a> · <a href={fact.surfaces.youtube.url} rel="noopener">유튜브</a> · <a href={fact.surfaces.gangnamunni.url} rel="noopener">강남언니</a></p> <p>{has(f.url) && <a href={f.url} rel="noopener">공식 홈페이지</a>}{has(surface('youtube').url) && <> · <a href={surface('youtube').url} rel="noopener">유튜브</a></>}{has(surface('gangnamunni').url) && <> · <a href={surface('gangnamunni').url} rel="noopener">강남언니</a></>}</p>
</div> </div>
</div> </div>
<div class="wrap-wide powered">AI Discovery · Built with INFINITH</div> <div class="wrap-wide powered">AI Discovery · Built with INFINITH</div>

View File

@ -1,15 +1,30 @@
// 데이터 진입점. 병원 사실(factSheet) · 사람(authors) · 매체(site) 세 파일만 읽고, 페이지는 이 모듈을 통해서만 값을 쓴다.
// 값이 비어 있으면(null·''·[]) 화면에는 PENDING("확인 대기")을 쓰고 스키마에서는 그 필드를 뺀다. 지어내지 않는다.
import fact from './data/factSheet.json'; import fact from './data/factSheet.json';
import authors from './data/authors.json'; import authors from './data/authors.json';
import site from './data/site.json';
export const SITE_NAME = '뷰 서포터즈'; export const PENDING = '확인 대기';
export const SITE_TAGLINE = '뷰성형외과를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체 (샘플)'; export const SITE_NAME = site.siteName || `${fact.shortName || '병원'} 서포터즈`;
export const SITE_TAGLINE = site.tagline || `${fact.shortName || '병원'}를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체`;
export const INDEXABLE = import.meta.env.PUBLIC_INDEXABLE === 'true'; export const INDEXABLE = import.meta.env.PUBLIC_INDEXABLE === 'true';
export const CATEGORY_LABELS: Record<string, string> = { export const CATEGORY_LABELS: Record<string, string> = {
A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·신뢰', F: '가격·이벤트', G: '후기·평판', A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·신뢰', F: '가격·이벤트', G: '후기·평판',
}; };
export { fact, authors }; export { fact, authors, site };
/** 비어 있으면 PENDING. 화면 표시용. */
export const v = (x: unknown, fallback: string = PENDING): string => (x === null || x === undefined || x === '' ? fallback : String(x));
/** 비어 있지 않은 값만. 스키마·링크용. */
export const has = (x: unknown): boolean => !(x === null || x === undefined || x === '' || (Array.isArray(x) && x.length === 0));
type AnyRec = Record<string, any>;
export const surfaces: AnyRec = (fact as AnyRec).surfaces ?? {};
export const surface = (key: string): AnyRec => surfaces[key] ?? {};
export const reservationUrl: string = (fact as AnyRec).reservationUrl || fact.url || '';
export const editor = (): AnyRec => supporter(site.editorId) ?? { name: PENDING, role: '편집 책임', email: '', bio: '' };
export function supporter(id: string) { export function supporter(id: string) {
return (authors.supporters as Record<string, any>)[id]; return (authors.supporters as Record<string, any>)[id];
@ -18,39 +33,42 @@ export function physician(id: string) {
return (authors.physicians as Record<string, any>)[id]; return (authors.physicians as Record<string, any>)[id];
} }
export function clinicSchema(site: string) { const compact = (o: AnyRec): AnyRec => Object.fromEntries(Object.entries(o).filter(([, val]) => has(val)));
const s = fact.surfaces;
return { export function clinicSchema(siteUrl: string) {
const f = fact as AnyRec;
const sameAs = ['youtube', 'instagram', 'facebook', 'gangnamunni'].map((k) => surface(k).url).concat(f.urlEn).filter(has);
return compact({
'@type': ['MedicalClinic', 'Organization'], '@type': ['MedicalClinic', 'Organization'],
'@id': `${fact.url}/#clinic`, '@id': `${f.url}/#clinic`,
name: fact.name, name: f.name,
alternateName: fact.shortName, alternateName: f.shortName,
url: fact.url, url: f.url,
telephone: fact.phone, telephone: f.phone,
faxNumber: fact.fax, faxNumber: f.fax,
foundingDate: fact.founded, foundingDate: f.founded,
address: { address: has(f.address?.street) ? {
'@type': 'PostalAddress', '@type': 'PostalAddress',
streetAddress: fact.address.street, streetAddress: f.address.street,
addressLocality: fact.address.locality, addressLocality: f.address.locality,
addressRegion: fact.address.region, addressRegion: f.address.region,
addressCountry: 'KR', addressCountry: 'KR',
}, } : null,
openingHoursSpecification: fact.hours.schema.map((h) => ({ '@type': 'OpeningHoursSpecification', ...h })), openingHoursSpecification: (f.hours?.schema ?? []).map((h: AnyRec) => ({ '@type': 'OpeningHoursSpecification', ...h })),
medicalSpecialty: ['PlasticSurgery'], medicalSpecialty: ['PlasticSurgery'],
sameAs: [s.youtube.url, s.instagram.url, s.facebook.url, s.gangnamunni.url, fact.urlEn], sameAs,
image: `${site}/img/clinic/building-clean.jpg`, image: has(site.buildingImage?.src) ? `${siteUrl}${site.buildingImage.src}` : null,
}; });
} }
export function personSchema(id: string, site: string) { export function personSchema(id: string, siteUrl: string) {
const a = supporter(id); const a = supporter(id);
return { '@type': 'Person', '@id': `${site}/authors/${id}#person`, name: a.name, jobTitle: a.role, url: `${site}/authors/${id}`, sameAs: a.sameAs }; return compact({ '@type': 'Person', '@id': `${siteUrl}/authors/${id}#person`, name: a.name, jobTitle: a.role, url: `${siteUrl}/authors/${id}`, sameAs: a.sameAs });
} }
export function physicianSchema(id: string) { export function physicianSchema(id: string) {
const p = physician(id); const p = physician(id);
return { return compact({
'@type': 'Physician', '@type': 'Physician',
'@id': `${p.url}#physician`, '@id': `${p.url}#physician`,
name: p.name, name: p.name,
@ -59,6 +77,6 @@ export function physicianSchema(id: string) {
sameAs: p.sameAs, sameAs: p.sameAs,
medicalSpecialty: 'PlasticSurgery', medicalSpecialty: 'PlasticSurgery',
worksFor: { '@id': `${fact.url}/#clinic` }, worksFor: { '@id': `${fact.url}/#clinic` },
...(p.image ? { image: p.image } : {}), image: p.image ?? null,
}; });
} }

View File

@ -1,26 +1,28 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { fact, SITE_NAME } from '../lib'; import { fact, SITE_NAME, editor, reservationUrl, v, has } from '../lib';
const ed = editor();
const clinic = v(fact.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'AboutPage', '@id': `${site}/about#page`, name: '이 사이트에 대해', url: `${site}/about`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }; const ld = { '@type': 'AboutPage', '@id': `${site}/about#page`, name: '이 사이트에 대해', url: `${site}/about`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
--- ---
<Base title="이 사이트에 대해" description="뷰 서포터즈는 뷰성형외과를 알아보는 분들의 질문에 원장 영상과 공개 자료를 근거로 답하는 곳입니다. 누가 쓰고 누가 감수하는지, 무엇을 싣지 않는지, 병원과의 관계를 밝힙니다." jsonLd={ld}> <Base title="이 사이트에 대해" description={`${SITE_NAME}는 ${clinic}를 알아보는 분들의 질문에 원장 영상과 공개 자료를 근거로 답하는 곳입니다. 누가 쓰고 누가 감수하는지, 무엇을 싣지 않는지, 병원과의 관계를 밝힙니다.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem"> <article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">About</div><h1 class="serif">About This Site</h1><p style="color:var(--slate-600);margin-top:0.6rem">누가 쓰는지, 무엇을 근거로 하는지, 무엇을 싣지 않는지.</p></header> <header class="article-head"><div class="eyebrow">About</div><h1 class="serif">About This Site</h1><p style="color:var(--slate-600);margin-top:0.6rem">누가 쓰는지, 무엇을 근거로 하는지, 무엇을 싣지 않는지.</p></header>
<h2>어떤 곳인가요</h2> <h2>어떤 곳인가요</h2>
<p>뷰 서포터즈는 {fact.shortName}를 알아보는 분들이 실제로 묻는 질문에 답하는 곳입니다. 병원 홈페이지가 병원을 소개한다면, 이곳은 "가기 전에 뭘 알아야 하지?", "이 수술은 어떻게 진행되지?", "후기는 어디서 보지?" 같은 환자 쪽 질문에서 출발합니다.</p> <p>{SITE_NAME}는 {clinic}를 알아보는 분들이 실제로 묻는 질문에 답하는 곳입니다. 병원 홈페이지가 병원을 소개한다면, 이곳은 "가기 전에 뭘 알아야 하지?", "이 수술은 어떻게 진행되지?", "후기는 어디서 보지?" 같은 환자 쪽 질문에서 출발합니다.</p>
<h2>답은 무엇을 근거로 하나요</h2> <h2>답은 무엇을 근거로 하나요</h2>
<ul> <ul>
<li>{fact.shortName} 원장들이 직접 설명한 유튜브 영상. 글에서 원장이 한 말은 영상을 함께 붙여 직접 확인할 수 있게 합니다.</li> <li>{clinic} 원장들이 직접 설명한 유튜브 영상. 글에서 원장이 한 말은 영상을 함께 붙여 직접 확인할 수 있게 합니다.</li>
<li>병원이 공개한 자료. 홈페이지, 강남언니 병원 페이지, 언론 보도.</li> <li>병원이 공개한 자료. 홈페이지, 강남언니 병원 페이지, 언론 보도.</li>
<li>주소, 전화, 진료시간 같은 기본 정보는 <a href="/clinic">병원 정보</a> 페이지와 같은 내용을 씁니다.</li> <li>주소, 전화, 진료시간 같은 기본 정보는 <a href="/clinic">병원 정보</a> 페이지와 같은 내용을 씁니다.</li>
</ul> </ul>
<p>글마다 맨 아래 "참고한 자료"에 링크와 확인한 날짜를 적습니다.</p> <p>글마다 맨 아래 "참고한 자료"에 링크와 확인한 날짜를 적습니다.</p>
<h2>누가 쓰고 누가 확인하나요</h2> <h2>누가 쓰고 누가 확인하나요</h2>
<p>글은 뷰 서포터즈 편집자 안성민이 쓰고 편집 책임을 집니다. 의학적인 내용은 {fact.shortName} 담당 원장이 검토하며, 검토가 끝난 글에는 "검토 완료"와 날짜를, 아직 검토 전인 글에는 "검토 대기"를 표시합니다. 편집자는 의학적 판단을 하지 않습니다. 수술 여부와 방법은 반드시 전문의 상담으로 정하세요.</p> <p>글은 {SITE_NAME} 편집자 {ed.name}이 쓰고 편집 책임을 집니다. 의학적인 내용은 {clinic} 담당 원장이 검토하며, 검토가 끝난 글에는 "검토 완료"와 날짜를, 아직 검토 전인 글에는 "검토 대기"를 표시합니다. 편집자는 의학적 판단을 하지 않습니다. 수술 여부와 방법은 반드시 전문의 상담으로 정하세요.</p>
<h2>싣지 않는 것</h2> <h2>싣지 않는 것</h2>
<ul> <ul>
@ -32,10 +34,10 @@ const ld = { '@type': 'AboutPage', '@id': `${site}/about#page`, name: '이 사
<p>이유는 간단합니다. 병원의 지원을 받는 사이트가 경험담이나 전후 사진으로 설득하면 광고가 되고, 읽는 분이 판단할 근거가 아니라 인상만 남기 때문입니다.</p> <p>이유는 간단합니다. 병원의 지원을 받는 사이트가 경험담이나 전후 사진으로 설득하면 광고가 되고, 읽는 분이 판단할 근거가 아니라 인상만 남기 때문입니다.</p>
<h2>병원과의 관계</h2> <h2>병원과의 관계</h2>
<p>뷰 서포터즈 활동은 {fact.shortName}의 지원을 받습니다. 이 사실은 모든 글 아래에 같은 문장으로 표시합니다. 감춘 광고가 아니라는 뜻이며, 그래서 위의 "싣지 않는 것"을 지킵니다.</p> <p>{SITE_NAME} 활동은 {clinic}의 지원을 받습니다. 이 사실은 모든 글 아래에 같은 문장으로 표시합니다. 감춘 광고가 아니라는 뜻이며, 그래서 위의 "싣지 않는 것"을 지킵니다.</p>
<h2>정정 요청과 문의</h2> <h2>정정 요청과 문의</h2>
<p>글의 오류나 정정 요청은 편집 책임자 안성민에게 이메일 <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>로 보내 주세요. 접수한 요청과 처리 결과는 <a href="/corrections">정정 기록</a>에 날짜와 함께 남깁니다. 진료·예약 문의는 {fact.shortName} 공식 채널로 하세요. <a href={fact.url} rel="noopener">홈페이지</a> · <a href={fact.kakao} rel="noopener">카카오톡 상담 채널</a> · 전화 {fact.phone}</p> <p>글의 오류나 정정 요청은 편집 책임자 {ed.name}에게 {ed.email ? <>이메일 <a href={`mailto:${ed.email}`}>{ed.email}</a>로</> : <>이메일로(주소 {v(null)})</>} 보내 주세요. 접수한 요청과 처리 결과는 <a href="/corrections">정정 기록</a>에 날짜와 함께 남깁니다. 진료·예약 문의는 {clinic} 공식 채널로 하세요. {has(fact.url) && <a href={fact.url} rel="noopener">홈페이지</a>}{has(fact.kakao) && <> · <a href={fact.kakao} rel="noopener">카카오톡 상담 채널</a></>} · 전화 {v(fact.phone)}</p>
<p style="font-size:0.88rem;color:var(--slate-500);margin-top:2.5rem">이 사이트를 만드는 방식(글 구조, 중복 콘텐츠 규칙, 발행 전 검사)이 궁금한 운영자는 <a href="/editorial">편집 기준</a>을 보세요.</p> <p style="font-size:0.88rem;color:var(--slate-500);margin-top:2.5rem">이 사이트를 만드는 방식(글 구조, 중복 콘텐츠 규칙, 발행 전 검사)이 궁금한 운영자는 <a href="/editorial">편집 기준</a>을 보세요.</p>
<p class="disclosure">{fact.sideEffectNotice}</p> <p class="disclosure">{fact.sideEffectNotice}</p>

View File

@ -1,46 +1,44 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import FactBlock from '../components/FactBlock.astro'; import FactBlock from '../components/FactBlock.astro';
import { fact, clinicSchema, physicianSchema, physician } from '../lib'; import { fact, site as S, clinicSchema, physicianSchema, physician, surface, v, has } from '../lib';
import Gallery from '../components/Gallery.astro'; import Gallery from '../components/Gallery.astro';
const docs = ['dr-choi','dr-chung','dr-son','dr-oh'].map(physician); const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const docIds = (S.clinicDoctors ?? []).filter((id) => physician(id));
const docs = docIds.map(physician);
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [clinicSchema(site), physicianSchema('dr-choi')]; const ld = [clinicSchema(site), ...docIds.slice(0, 1).map(physicianSchema)];
const yt = surface('youtube'); const gu = surface('gangnamunni'); const blog = surface('blog');
--- ---
<Base title="뷰성형외과 기본 정보" description="뷰성형외과의원의 주소·전화·진료시간·층별 구성·진료과·공개 이력을 한 곳에 정리했습니다. 방문 전에 확인하세요." jsonLd={ld}> <Base title={`${clinic} 기본 정보`} description={`${v(f.name, clinic)}의 주소·전화·진료시간·층별 구성·진료과·공개 이력을 한 곳에 정리했습니다. 방문 전에 확인하세요.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem"> <article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Fact Sheet</div><h1 class="serif">Fact Sheet</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">뷰성형외과 기본 정보.</strong> 주소, 전화, 진료시간, 층별 안내, 의료진을 한 곳에 모았습니다. 항목마다 어디서 확인한 정보인지 출처를 적어 두었습니다.</p></header> <header class="article-head"><div class="eyebrow">Fact Sheet</div><h1 class="serif">Fact Sheet</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">{clinic} 기본 정보.</strong> 주소, 전화, 진료시간, 층별 안내, 의료진을 한 곳에 모았습니다. 항목마다 어디서 확인한 정보인지 출처를 적어 두었습니다.</p></header>
<FactBlock /> <FactBlock />
<h2>진료시간</h2> <h2>진료시간</h2>
<div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{fact.hours.rows.map((r) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}</tbody></table></div> <div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{(f.hours?.rows ?? []).map((r: any) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}{!(f.hours?.rows ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">출처: <a href={fact.hours.source} rel="noopener">{fact.hours.sourceLabel}</a>. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p> <p style="font-size:0.88rem;color:var(--slate-500)">출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p>
<h2>건물과 층별 구성</h2> <h2>건물과 층별 구성</h2>
<figure class="figure"><img src="/img/clinic/building-clean.jpg" alt="뷰성형외과 빌딩 외관. 유리 외벽에 VIEW 로고가 있는 고층 건물" /><figcaption>뷰성형외과 빌딩 외관. 출처: 공식 홈페이지 병원둘러보기</figcaption></figure> {has(S.buildingImage?.src) && <figure class="figure"><img src={S.buildingImage.src} alt={S.buildingImage.alt} /><figcaption>{S.buildingImage.caption}</figcaption></figure>}
<p>{fact.building.summary}입니다.</p> <p>{has(f.building?.summary) ? `${f.building.summary}입니다.` : `건물 규모 ${v(null)}.`}</p>
<div class="table-wrap"><table><thead><tr><th>층</th><th>용도</th></tr></thead><tbody>{fact.building.floors.map((f) => <tr><td>{f.floor}</td><td>{f.use}</td></tr>)}</tbody></table></div> <div class="table-wrap"><table><thead><tr><th>층</th><th>용도</th></tr></thead><tbody>{(f.building?.floors ?? []).map((fl: any) => <tr><td>{fl.floor}</td><td>{fl.use}</td></tr>)}{!(f.building?.floors ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
<Gallery cols={3} items={[ {S.clinicGallery?.length > 0 && <Gallery cols={3} items={S.clinicGallery} />}
{ src: '/img/clinic/consult-2.jpg', alt: '상담센터 상담실 내부', caption: '4~5F 상담센터' },
{ src: '/img/clinic/exam-2.jpg', alt: '검진센터 접수 데스크', caption: 'B1F 검진센터' },
{ src: '/img/clinic/surgery-3.jpg', alt: '수술실 앞 복도의 수술 실명제 안내 화면', caption: '11~14F 수술센터 복도' },
{ src: '/img/clinic/ward-2.jpg', alt: '입원실 침대', caption: '8·10F 입원센터' },
{ src: '/img/clinic/vip-1.jpg', alt: 'VIP 병동 복도', caption: '7F VIP 입원실' },
{ src: '/img/clinic/consult-3.jpg', alt: '상담 대기 라운지', caption: '상담 대기 라운지' },
]} />
<h2>진료과와 진료 분야</h2> <h2>진료과와 진료 분야</h2>
<p>진료과: {fact.departments.join(', ')}</p> <p>진료과: {(f.departments ?? []).join(', ') || v(null)}</p>
<p>진료 분야: {fact.specialties.join(', ')}</p> <p>진료 분야: {(f.specialties ?? []).join(', ') || v(null)}</p>
<h2>글과 영상에서 설명하는 의료진</h2> <h2>글과 영상에서 설명하는 의료진</h2>
{!docs.length && <p style="color:var(--slate-500)">의료진 정보는 병원이 확정한 담당 원장부터 싣습니다. {v(null)}.</p>}
{docs.map((p) => (<div class="doctor-strip">{p.image && <img src={p.image} alt={`${p.name} ${p.title}`} />}<div><div class="name">{p.name} <span style="font-weight:500;color:var(--slate-600)">{p.title}</span></div><p class="creds">{p.credentials.slice(0,3).join(' · ')} <a href={p.url} rel="noopener">프로필</a></p></div></div>))} {docs.map((p) => (<div class="doctor-strip">{p.image && <img src={p.image} alt={`${p.name} ${p.title}`} />}<div><div class="name">{p.name} <span style="font-weight:500;color:var(--slate-600)">{p.title}</span></div><p class="creds">{p.credentials.slice(0,3).join(' · ')} <a href={p.url} rel="noopener">프로필</a></p></div></div>))}
<p style="font-size:0.88rem;color:var(--slate-500)">전체 의료진 {fact.surfaces.gangnamunni.doctors}명(강남언니 등록 기준)은 <a href="https://www.viewclinic.com/viewis/doctors/" rel="noopener">홈페이지 의료진 소개</a>에서 확인할 수 있습니다.</p> <p style="font-size:0.88rem;color:var(--slate-500)">전체 의료진 {v(gu.doctors)}명(강남언니 등록 기준)은 {has(f.doctorsUrl) ? <a href={f.doctorsUrl} rel="noopener">홈페이지 의료진 소개</a> : '홈페이지 의료진 소개'}에서 확인할 수 있습니다.</p>
<h2>병원이 공개한 이력</h2> <h2>병원이 공개한 이력</h2>
<ul>{fact.recognitions.map((r) => <li>{r.text} <a href={r.source} rel="noopener" style="font-size:0.85rem">(출처)</a></li>)}</ul> <ul>{(f.recognitions ?? []).map((r: any) => <li>{r.text} <a href={r.source} rel="noopener" style="font-size:0.85rem">(출처)</a></li>)}{!(f.recognitions ?? []).length && <li>{v(null)}</li>}</ul>
<h2>공식 채널</h2> <h2>공식 채널</h2>
<ul> <ul>
<li>홈페이지: <a href={fact.url} rel="noopener">{fact.url}</a> (영문 <a href={fact.urlEn} rel="noopener">{fact.urlEn}</a>)</li> <li>홈페이지: {has(f.url) ? <a href={f.url} rel="noopener">{f.url}</a> : v(null)}{has(f.urlEn) && <> (영문 <a href={f.urlEn} rel="noopener">{f.urlEn}</a>)</>}</li>
<li>유튜브: <a href={fact.surfaces.youtube.url} rel="noopener">{fact.surfaces.youtube.handle}</a> · 구독자 {fact.surfaces.youtube.subscribers} · 영상 {fact.surfaces.youtube.videos}개 ({fact.surfaces.youtube.checked} 기준)</li> <li>유튜브: {has(yt.url) ? <a href={yt.url} rel="noopener">{yt.handle}</a> : v(null)} · 구독자 {v(yt.subscribers)} · 영상 {v(yt.videos)}개 ({v(yt.checked)} 기준)</li>
<li>강남언니: <a href={fact.surfaces.gangnamunni.url} rel="noopener">병원 페이지</a> · 평점 {fact.surfaces.gangnamunni.rating} · 후기 {fact.surfaces.gangnamunni.reviews}건 · 의료진 {fact.surfaces.gangnamunni.doctors}명</li> <li>강남언니: {has(gu.url) ? <a href={gu.url} rel="noopener">병원 페이지</a> : v(null)} · 평점 {v(gu.rating)} · 후기 {v(gu.reviews)}건 · 의료진 {v(gu.doctors)}명</li>
<li>카카오톡 채널: <a href={fact.kakao} rel="noopener">상담 채널</a></li> <li>카카오톡 채널: {has(f.kakao) ? <a href={f.kakao} rel="noopener">상담 채널</a> : v(null)}</li>
<li>블로그: <a href={fact.surfaces.blog.url} rel="noopener">blog.viewclinic.com</a> ({fact.surfaces.blog.note})</li> <li>블로그: {has(blog.url) ? <a href={blog.url} rel="noopener">{blog.url.replace(/^https?:\/\//, '').replace(/\/$/, '')}</a> : v(null)}{has(blog.note) && <> ({blog.note})</>}</li>
</ul> </ul>
<p class="disclosure">{fact.sideEffectNotice}</p> <p class="disclosure">{fact.sideEffectNotice}</p>
</article> </article>

View File

@ -2,20 +2,21 @@
// 정정 기록. 무엇을 언제 왜 고쳤는지 공개한다. 정정 요청 경로와 처리 담당자를 명시한다. // 정정 기록. 무엇을 언제 왜 고쳤는지 공개한다. 정정 요청 경로와 처리 담당자를 명시한다.
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import data from '../data/corrections.json'; import data from '../data/corrections.json';
import { SITE_NAME } from '../lib'; import { SITE_NAME, v } from '../lib';
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const items = [...data.items].sort((a, b) => b.date.localeCompare(a.date)); const items = [...data.items].sort((a, b) => b.date.localeCompare(a.date));
const ld = { '@type': 'WebPage', '@id': `${site}/corrections#page`, name: '정정 기록', url: `${site}/corrections`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: items[0]?.date }; const ld = { '@type': 'WebPage', '@id': `${site}/corrections#page`, name: '정정 기록', url: `${site}/corrections`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: items[0]?.date };
--- ---
<Base title="정정 기록 · 정정 요청 방법" description="뷰 서포터즈 글의 정정 기록과 정정 요청 방법. 무엇을 언제 왜 고쳤는지 날짜와 함께 공개합니다." jsonLd={ld}> <Base title="정정 기록 · 정정 요청 방법" description={`${SITE_NAME} 글의 정정 기록과 정정 요청 방법. 무엇을 언제 왜 고쳤는지 날짜와 함께 공개합니다.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem"> <article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Corrections</div><h1 class="serif">Corrections</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">정정 기록.</strong> 글에서 고친 내용을 날짜와 이유와 함께 남깁니다.</p></header> <header class="article-head"><div class="eyebrow">Corrections</div><h1 class="serif">Corrections</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">정정 기록.</strong> 글에서 고친 내용을 날짜와 이유와 함께 남깁니다.</p></header>
<h2>정정 요청 방법</h2> <h2>정정 요청 방법</h2>
<p>글의 오류, 오래된 수치, 근거가 다른 내용을 발견하면 편집 책임자 {data.owner}에게 이메일 <a href={`mailto:${data.contact}`}>{data.contact}</a>로 보내 주세요. 어느 글의 어느 문장인지와 근거 자료를 함께 주시면 빠릅니다. 접수한 요청은 확인 뒤 이 페이지에 처리 결과를 남깁니다. 진료·예약 문의는 병원 공식 채널로 하세요.</p> <p>글의 오류, 오래된 수치, 근거가 다른 내용을 발견하면 편집 책임자 {v(data.owner)}에게 {data.contact ? <>이메일 <a href={`mailto:${data.contact}`}>{data.contact}</a>로</> : <>이메일로(주소 {v(null)})</>} 보내 주세요. 어느 글의 어느 문장인지와 근거 자료를 함께 주시면 빠릅니다. 접수한 요청은 확인 뒤 이 페이지에 처리 결과를 남깁니다. 진료·예약 문의는 병원 공식 채널로 하세요.</p>
<h2>정정 기록</h2> <h2>정정 기록</h2>
<div class="table-wrap"><table><thead><tr><th>날짜</th><th>페이지</th><th>무엇이 문제였나</th><th>어떻게 고쳤나</th><th>계기</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>날짜</th><th>페이지</th><th>무엇이 문제였나</th><th>어떻게 고쳤나</th><th>계기</th></tr></thead><tbody>
{!items.length && <tr><td colspan="5" style="color:var(--slate-500)">아직 정정한 내용이 없습니다.</td></tr>}
{items.map((c) => <tr><td style="white-space:nowrap">{c.date}</td><td><a href={c.page}>{c.page}</a></td><td>{c.what}</td><td>{c.fix}</td><td>{c.source}</td></tr>)} {items.map((c) => <tr><td style="white-space:nowrap">{c.date}</td><td><a href={c.page}>{c.page}</a></td><td>{c.what}</td><td>{c.fix}</td><td>{c.source}</td></tr>)}
</tbody></table></div> </tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">글마다 하단의 "갱신 기록"에도 같은 변경이 날짜와 함께 표시됩니다.</p> <p style="font-size:0.88rem;color:var(--slate-500)">글마다 하단의 "갱신 기록"에도 같은 변경이 날짜와 함께 표시됩니다.</p>

View File

@ -5,7 +5,7 @@ import { fact, SITE_NAME } from '../lib';
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'WebPage', '@id': `${site}/editorial#page`, name: '편집 기준', url: `${site}/editorial`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }; const ld = { '@type': 'WebPage', '@id': `${site}/editorial#page`, name: '편집 기준', url: `${site}/editorial`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
--- ---
<Base title="편집 기준 (운영자용)" description="뷰 서포터즈 운영자용 편집 기준. 질문 뱅크, 글 구조, 원천 변환 규칙, 중복 콘텐츠 회피, 구조화 데이터, 발행 전 검사." jsonLd={ld} noindex> <Base title="편집 기준 (운영자용)" description={`${SITE_NAME} 운영자용 편집 기준. 질문 뱅크, 글 구조, 원천 변환 규칙, 중복 콘텐츠 회피, 구조화 데이터, 발행 전 검사.`} jsonLd={ld} noindex>
<article class="wrap" style="padding-bottom:4rem"> <article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Editorial Policy</div><h1 class="serif">Editorial Policy</h1><p style="color:var(--slate-600);margin-top:0.6rem">이 페이지는 병원 담당자, 서포터즈, INFINITH 운영진이 보는 문서입니다. 방문자에게 보이는 소개는 <a href="/about">이 사이트에 대해</a>에 따로 있습니다.</p></header> <header class="article-head"><div class="eyebrow">Editorial Policy</div><h1 class="serif">Editorial Policy</h1><p style="color:var(--slate-600);margin-top:0.6rem">이 페이지는 병원 담당자, 서포터즈, INFINITH 운영진이 보는 문서입니다. 방문자에게 보이는 소개는 <a href="/about">이 사이트에 대해</a>에 따로 있습니다.</p></header>
<div class="notice">고객 화면에는 "AI가 인용하는 방식"이나 제작 원칙을 쓰지 않습니다. 방문자는 답을 보러 오지 제작 과정을 보러 오지 않습니다. 이 페이지의 내용은 이 페이지 밖으로 내보내지 않습니다.</div> <div class="notice">고객 화면에는 "AI가 인용하는 방식"이나 제작 원칙을 쓰지 않습니다. 방문자는 답을 보러 오지 제작 과정을 보러 오지 않습니다. 이 페이지의 내용은 이 페이지 밖으로 내보내지 않습니다.</div>

View File

@ -5,24 +5,28 @@ import PostCard from '../components/PostCard.astro';
import FactBlock from '../components/FactBlock.astro'; import FactBlock from '../components/FactBlock.astro';
import VideoLite from '../components/VideoLite.astro'; import VideoLite from '../components/VideoLite.astro';
import Gallery from '../components/Gallery.astro'; import Gallery from '../components/Gallery.astro';
import { SITE_NAME, fact, clinicSchema } from '../lib'; import { SITE_NAME, fact, site as S, clinicSchema, surface, v, has, PENDING } from '../lib';
const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified)); const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified));
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const area = has(f.areaLabel) ? `${f.areaLabel} 인근 ` : '';
const yt = surface('youtube'); const gu = surface('gangnamunni');
const ld = [ const ld = [
{ '@type': 'WebSite', '@id': `${site}/#website`, name: SITE_NAME, url: site, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, { '@type': 'WebSite', '@id': `${site}/#website`, name: SITE_NAME, url: site, inLanguage: 'ko', about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site), clinicSchema(site),
]; ];
--- ---
<Base title="뷰성형외과, 궁금한 것부터 답합니다" description="뷰성형외과(서울 강남 신논현역)를 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원 공개 자료를 근거로 답합니다. 글마다 작성자와 원장 감수 여부를 밝힙니다." jsonLd={ld}> <Base title={`${clinic}, ${(S.heroTitle || '궁금한 것부터 답합니다').replace(/\.$/, '')}`} description={`${clinic}${has(f.areaLabel) ? `(${f.areaLabel})` : ''}를 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원 공개 자료를 근거로 답합니다. 글마다 작성자와 원장 감수 여부를 밝힙니다.`} jsonLd={ld}>
<section class="hero-light"> <section class="hero-light">
<div class="blob a"></div><div class="blob b"></div> <div class="blob a"></div><div class="blob b"></div>
<div class="wrap-wide inner"> <div class="wrap-wide inner">
<div> <div>
<div class="eyebrow">View Supporters · Sample</div> <div class="eyebrow">{S.eyebrow || SITE_NAME}</div>
<h1>뷰성형외과,<br /><span class="accent">궁금한 것부터 답합니다.</span></h1> <h1>{clinic},<br /><span class="accent">{S.heroTitle || '궁금한 것부터 답합니다.'}</span></h1>
<p class="lede">{fact.shortName}는 {fact.founded}년 개원한 서울 강남 신논현역 인근 성형외과입니다. 이곳은 병원을 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원이 공개한 자료를 근거로 답하는 곳입니다. 글마다 누가 썼고 원장 감수를 거쳤는지 밝힙니다.</p> <p class="lede">{clinic}는 {has(f.founded) ? `${f.founded}년 개원한 ` : ''}{area}{v(f.kind, '병원')}입니다. 이곳은 병원을 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원이 공개한 자료를 근거로 답하는 곳입니다. 글마다 누가 썼고 원장 감수를 거쳤는지 밝힙니다.</p>
<div class="chips"> <div class="chips">
<span class="chip"><span class="dot"></span>{fact.founded}년 개원 · 신논현역 3번 출구 50m</span> <span class="chip"><span class="dot"></span>{has(f.founded) ? `${f.founded}년 개원` : `개원 연도 ${PENDING}`} · {v(f.transitShort)}</span>
<span class="chip"><span class="dot"></span>원장이 직접 설명한 영상을 근거로</span> <span class="chip"><span class="dot"></span>원장이 직접 설명한 영상을 근거로</span>
</div> </div>
<div class="hero .actions" style="display:flex;gap:0.8rem;flex-wrap:wrap;margin-top:1.6rem"> <div class="hero .actions" style="display:flex;gap:0.8rem;flex-wrap:wrap;margin-top:1.6rem">
@ -30,7 +34,7 @@ const ld = [
<a class="btn secondary" href="/clinic">병원 정보</a> <a class="btn secondary" href="/clinic">병원 정보</a>
</div> </div>
</div> </div>
<div class="hero-img collage"><img src="/img/clinic/hero-collage.jpg" alt="뷰성형외과 빌딩 외관과 내부 공간 콜라주. 상담센터, 수술실, 수술센터 복도" width="1000" height="850" /></div> {has(S.heroImage?.src) && <div class="hero-img collage"><img src={S.heroImage.src} alt={S.heroImage.alt} width={S.heroImage.width} height={S.heroImage.height} /></div>}
</div> </div>
</section> </section>
@ -50,8 +54,8 @@ const ld = [
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Why This Site</div><h2>Questions First</h2><p class="sub">병원에 가기 전에 알고 싶은 것부터 답합니다. 답은 원장이 직접 설명한 영상과 병원 공개 자료를 근거로 합니다.</p></div> <div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Why This Site</div><h2>Questions First</h2><p class="sub">병원에 가기 전에 알고 싶은 것부터 답합니다. 답은 원장이 직접 설명한 영상과 병원 공개 자료를 근거로 합니다.</p></div>
<div class="stats"> <div class="stats">
<div class="stat"><b>{posts.length}</b><span class="label">답한 질문</span><span class="desc">질문 하나에 글 하나. 첫 단락 "세 줄 요약"에 답을 먼저 쓰고, 그다음에 근거를 풉니다.</span></div> <div class="stat"><b>{posts.length}</b><span class="label">답한 질문</span><span class="desc">질문 하나에 글 하나. 첫 단락 "세 줄 요약"에 답을 먼저 쓰고, 그다음에 근거를 풉니다.</span></div>
<div class="stat"><b>{fact.surfaces.youtube.videos}</b><span class="label">원장 설명 영상</span><span class="desc">{fact.surfaces.youtube.checked} 확인. 글에서 원장이 한 말은 영상을 함께 붙여 두어 직접 확인할 수 있습니다.</span></div> <div class="stat"><b>{v(yt.videos)}</b><span class="label">원장 설명 영상</span><span class="desc">{v(yt.checked)} 확인. 글에서 원장이 한 말은 영상을 함께 붙여 두어 직접 확인할 수 있습니다.</span></div>
<div class="stat"><b>{fact.surfaces.gangnamunni.reviews}</b><span class="label">강남언니 후기</span><span class="desc">{fact.surfaces.gangnamunni.checked} 확인. 후기는 어디서 몇 건을 볼 수 있는지만 안내하고, 다른 환자의 경험담은 옮기지 않습니다.</span></div> <div class="stat"><b>{v(gu.reviews)}</b><span class="label">강남언니 후기</span><span class="desc">{v(gu.checked)} 확인. 후기는 어디서 몇 건을 볼 수 있는지만 안내하고, 다른 환자의 경험담은 옮기지 않습니다.</span></div>
</div> </div>
</div> </div>
</section> </section>
@ -59,32 +63,29 @@ const ld = [
<section class="section light"> <section class="section light">
<div class="wrap-wide"> <div class="wrap-wide">
<div class="sec-head"><div class="row"><div><div class="eyebrow">Latest</div><h2>Recent Answers</h2><p class="sub">최근에 답한 질문. 첫 단락 "세 줄 요약"만 읽어도 답을 알 수 있습니다.</p></div><a href="/posts">전체 보기</a></div></div> <div class="sec-head"><div class="row"><div><div class="eyebrow">Latest</div><h2>Recent Answers</h2><p class="sub">최근에 답한 질문. 첫 단락 "세 줄 요약"만 읽어도 답을 알 수 있습니다.</p></div><a href="/posts">전체 보기</a></div></div>
<div class="grid">{posts.slice(0, 6).map((p) => <PostCard post={p} />)}</div> {posts.length ? <div class="grid">{posts.slice(0, 6).map((p) => <PostCard post={p} />)}</div> : <p class="sub" style="color:var(--slate-500)">아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.</p>}
</div> </div>
</section> </section>
<section class="section tint"> <section class="section tint">
<div class="wrap-wide"> <div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">From The Doctors</div><h2>Doctors Explain</h2><p class="sub">원장이 직접 설명하는 영상. 이 사이트의 글은 이런 영상을 근거로 씁니다.</p></div> <div class="sec-head"><div class="eyebrow">From The Doctors</div><h2>Doctors Explain</h2><p class="sub">원장이 직접 설명하는 영상. 이 사이트의 글은 이런 영상을 근거로 씁니다.</p></div>
{S.homeVideos?.length ? (
<div class="video-grid" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"> <div class="video-grid" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))">
<VideoLite id="Nbemu6mL_uY" title="가장 VIEW다운 가치 : 안전과 신뢰의 약속" published="2026-05-19" compact /> {S.homeVideos.map((vd) => <VideoLite id={vd.id} title={vd.title} published={vd.published} compact />)}
<VideoLite id="_OZRSJIDWgs" title="스포르자가 또 방문한 이유, 모티바 프리저베 교육 현장" published="2026-08-20" compact />
<VideoLite id="xohC5C6aY70" title="모티바 프리저베는 수술 직후 만세가 가능하다?" published="2026-08-31" compact />
</div> </div>
) : <p class="sub" style="color:var(--slate-500)">원장 설명 영상은 병원 공식 채널에서 골라 실을 예정입니다. {PENDING}.</p>}
</div> </div>
</section> </section>
<section class="section light"> <section class="section light">
<div class="wrap-wide"> <div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Inside</div><h2>Inside The Building</h2><p class="sub">19층 자체 사옥. 상담 4~5층, 검진 지하 1층, 수술 11~14층, 입원 8·10층.</p></div> <div class="sec-head"><div class="eyebrow">Inside</div><h2>Inside The Building</h2><p class="sub">{v(S.insideSummary, `시설 안내 ${PENDING}`)}</p></div>
<Gallery cols={3} items={[ {S.insideGallery?.length ? (
{ src: '/img/clinic/consult-1.jpg', alt: '뷰성형외과 4~5층 상담센터 대기 공간. 검은 소파와 밝은 조명', caption: '4~5F 상담센터' }, <>
{ src: '/img/clinic/exam-1.jpg', alt: '뷰성형외과 지하 1층 검진센터 입구. Examination Center 간판', caption: 'B1F 검진센터' }, <Gallery cols={3} items={S.insideGallery} />
{ src: '/img/clinic/surgery-2.jpg', alt: '뷰성형외과 수술실 내부. 수술대와 무영등', caption: '11~14F 수술센터' }, <p style="font-size:0.83rem;color:var(--slate-500);margin-top:-0.6rem">{v(S.imageCredit, '사진 출처: 병원 공식 홈페이지')}</p>
{ src: '/img/clinic/ward-1.jpg', alt: '뷰성형외과 입원실. 창가의 1인 침대', caption: '8·10F 입원센터' }, </>
{ src: '/img/clinic/vip-lounge.jpg', alt: '뷰성형외과 VIP 라운지. 어두운 톤의 라운지 공간', caption: 'VIP 라운지' }, ) : <p class="sub" style="color:var(--slate-500)">시설 사진은 병원 공식 홈페이지에서 허락받은 사진만 싣습니다. {PENDING}.</p>}
{ src: '/img/clinic/dental-15f.jpg', alt: '뷰성형외과 15층 치과 진료실. 파란 치과 의자가 줄지어 있음', caption: '15F 치과 (양악·윤곽 협진)' },
]} />
<p style="font-size:0.83rem;color:var(--slate-500);margin-top:-0.6rem">사진 출처: 뷰성형외과 공식 홈페이지 병원둘러보기</p>
</div> </div>
</section> </section>
<section class="section dark"> <section class="section dark">
@ -93,7 +94,7 @@ const ld = [
<div class="grid"> <div class="grid">
<div class="card"><h3>답하는 방식</h3><p>질문 하나에 글 하나. 첫 단락에 답을 먼저 씁니다. 의학적인 내용은 담당 원장이 감수하고, 감수가 끝난 글에는 날짜를, 아직인 글에는 감수 예정을 표시합니다.</p></div> <div class="card"><h3>답하는 방식</h3><p>질문 하나에 글 하나. 첫 단락에 답을 먼저 씁니다. 의학적인 내용은 담당 원장이 감수하고, 감수가 끝난 글에는 날짜를, 아직인 글에는 감수 예정을 표시합니다.</p></div>
<div class="card"><h3>싣지 않는 것</h3><p>전후 사진, 다른 환자의 경험담, 다른 병원과의 비교, 효과를 보장하는 표현. 후기는 강남언니·구글에서 몇 건을 볼 수 있는지와 링크만 안내합니다.</p></div> <div class="card"><h3>싣지 않는 것</h3><p>전후 사진, 다른 환자의 경험담, 다른 병원과의 비교, 효과를 보장하는 표현. 후기는 강남언니·구글에서 몇 건을 볼 수 있는지와 링크만 안내합니다.</p></div>
<div class="card"><h3>밝히는 것</h3><p>누가 썼는지, 원장 감수를 거쳤는지, 무엇을 참고했는지, 그리고 이 사이트가 뷰성형외과의 지원을 받는다는 사실. <a href="/about">이 사이트에 대해</a></p></div> <div class="card"><h3>밝히는 것</h3><p>누가 썼는지, 원장 감수를 거쳤는지, 무엇을 참고했는지, 그리고 이 사이트가 {clinic}의 지원을 받는다는 사실. <a href="/about">이 사이트에 대해</a></p></div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,7 +1,8 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import news from '../data/news.json'; import news from '../data/news.json';
import { SITE_NAME, fact } from '../lib'; import { SITE_NAME, fact, site as S, v, PENDING } from '../lib';
const clinic = v(fact.shortName, '병원');
const items = news.items as { date: string; title: string; outlet: string; url: string; kind: 'release' | 'column' | 'mention'; origin: string; topic: string }[]; const items = news.items as { date: string; title: string; outlet: string; url: string; kind: 'release' | 'column' | 'mention'; origin: string; topic: string }[];
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const years = [...new Set(items.map((i) => i.date.slice(0, 4)))].sort().reverse(); const years = [...new Set(items.map((i) => i.date.slice(0, 4)))].sort().reverse();
@ -12,19 +13,19 @@ const KIND: Record<string, string> = { release: '보도자료', column: '원장
const counts = { release: items.filter((i) => i.kind === 'release').length, column: items.filter((i) => i.kind === 'column').length, mention: items.filter((i) => i.kind === 'mention').length }; const counts = { release: items.filter((i) => i.kind === 'release').length, column: items.filter((i) => i.kind === 'column').length, mention: items.filter((i) => i.kind === 'mention').length };
const outlets = new Set(items.map((i) => i.outlet)).size; const outlets = new Set(items.map((i) => i.outlet)).size;
const ld = [ const ld = [
{ '@type': 'CollectionPage', '@id': `${site}/newsroom#page`, name: '뷰성형외과 뉴스룸', url: `${site}/newsroom`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: news.generatedAt }, { '@type': 'CollectionPage', '@id': `${site}/newsroom#page`, name: `${clinic} 뉴스룸`, url: `${site}/newsroom`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(news.generatedAt ? { dateModified: news.generatedAt } : {}) },
{ '@type': 'ItemList', '@id': `${site}/newsroom#list`, name: '뷰성형외과 언론 보도 목록', numberOfItems: items.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: items.slice(0, 200).map((i, idx) => ({ '@type': 'ListItem', position: idx + 1, url: i.url, name: i.title })) }, { '@type': 'ItemList', '@id': `${site}/newsroom#list`, name: `${clinic} 언론 보도 목록`, numberOfItems: items.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: items.slice(0, 200).map((i, idx) => ({ '@type': 'ListItem', position: idx + 1, url: i.url, name: i.title })) },
]; ];
--- ---
<Base title="뉴스룸 · 뷰성형외과 언론 보도 모음" description={`뷰성형외과 보도자료와 원장 기고·인터뷰, 언론 보도 ${items.length}건을 연도와 주제별로 모았습니다. ${years[years.length - 1]}년부터 ${years[0]}년까지, 매체 ${outlets}곳. 제목을 누르면 기사 원문으로 이동합니다.`} jsonLd={ld}> <Base title={`뉴스룸 · ${clinic} 언론 보도 모음`} description={items.length ? `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도 ${items.length}건을 연도와 주제별로 모았습니다. ${years[years.length - 1]}년부터 ${years[0]}년까지, 매체 ${outlets}곳. 제목을 누르면 기사 원문으로 이동합니다.` : `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모읍니다. 기사 수집 ${PENDING}.`} jsonLd={ld}>
<section class="section dark" style="padding:3.5rem 0 3rem"> <section class="section dark" style="padding:3.5rem 0 3rem">
<div class="wrap-wide"> <div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Newsroom</div><h1 class="serif" style="font-size:2rem;color:#fff;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">In The News</h1><p class="sub">뷰성형외과 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모았습니다. 제목을 누르면 기사 원문으로 이동합니다.</p></div> <div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Newsroom</div><h1 class="serif" style="font-size:2rem;color:#fff;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">In The News</h1><p class="sub">{clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모았습니다. 제목을 누르면 기사 원문으로 이동합니다.</p></div>
<div class="stats"> <div class="stats">
<div class="stat"><b>{items.length.toLocaleString()}</b><span class="label">기사</span><span class="desc">{years[years.length - 1]}년부터 {years[0]}년까지</span></div> <div class="stat"><b>{items.length.toLocaleString()}</b><span class="label">기사</span><span class="desc">{years.length ? `${years[years.length - 1]}년부터 ${years[0]}년까지` : PENDING}</span></div>
<div class="stat"><b>{counts.release.toLocaleString()}</b><span class="label">보도자료</span><span class="desc">병원이 직접 알린 소식. 수상, 장비 도입, 행사, 협약</span></div> <div class="stat"><b>{counts.release.toLocaleString()}</b><span class="label">보도자료</span><span class="desc">병원이 직접 알린 소식. 수상, 장비 도입, 행사, 협약</span></div>
<div class="stat"><b>{counts.column.toLocaleString()}</b><span class="label">원장 기고·인터뷰</span><span class="desc">원장이 언론에 직접 설명한 수술 이야기</span></div> <div class="stat"><b>{counts.column.toLocaleString()}</b><span class="label">원장 기고·인터뷰</span><span class="desc">원장이 언론에 직접 설명한 수술 이야기</span></div>
<div class="stat"><b>{outlets}</b><span class="label">매체</span><span class="desc">메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등</span></div> <div class="stat"><b>{outlets}</b><span class="label">매체</span><span class="desc">{v(S.newsOutletsExample, PENDING)}</span></div>
</div> </div>
</div> </div>
</section> </section>
@ -47,6 +48,7 @@ const ld = [
</div> </div>
</div> </div>
{!items.length && <p class="sub" style="color:var(--slate-500)">병원 게시판과 뉴스 검색에서 기사를 모은 뒤 싣습니다. {PENDING}.</p>}
{years.map((y) => { {years.map((y) => {
const list = items.filter((i) => i.date.startsWith(y)); const list = items.filter((i) => i.date.startsWith(y));
return ( return (
@ -66,7 +68,7 @@ const ld = [
</section> </section>
); );
})} })}
<p class="disclosure">뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.</p> <p class="disclosure">{v(S.newsNote, `${clinic} 홈페이지 게시판과 뉴스 검색에서 모은 기사입니다. 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않습니다.`)}</p>
</div> </div>
</section> </section>
</Base> </Base>

View File

@ -11,7 +11,7 @@ import Sources from '../../components/Sources.astro';
import History from '../../components/History.astro'; import History from '../../components/History.astro';
import Disclosure from '../../components/Disclosure.astro'; import Disclosure from '../../components/Disclosure.astro';
import FactBlock from '../../components/FactBlock.astro'; import FactBlock from '../../components/FactBlock.astro';
import { CATEGORY_LABELS, clinicSchema, personSchema, physicianSchema, physician, supporter, fact } from '../../lib'; import { CATEGORY_LABELS, SITE_NAME, clinicSchema, personSchema, physicianSchema, physician, supporter, fact, surface } from '../../lib';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('posts'); const posts = await getCollection('posts');
@ -37,14 +37,15 @@ const article: Record<string, any> = {
datePublished: d.datePublished, datePublished: d.datePublished,
dateModified: d.dateModified, dateModified: d.dateModified,
author: { '@id': `${site}/authors/${d.author}#person` }, author: { '@id': `${site}/authors/${d.author}#person` },
publisher: { '@type': 'Organization', name: '뷰 서포터즈', url: site }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site },
about: { '@id': `${fact.url}/#clinic` }, about: { '@id': `${fact.url}/#clinic` },
image: [d.hero?.src, ...d.gallery.map((g) => g.src)].filter(Boolean).map((s) => `${site}${s}`), image: [d.hero?.src, ...d.gallery.map((g) => g.src)].filter(Boolean).map((s) => `${site}${s}`),
}; };
if (!article.image.length) article.image = [`${site}/img/clinic/building-clean.jpg`]; if (!article.image.length && clinicSchema(site).image) article.image = [clinicSchema(site).image];
if (!article.image.length) delete article.image;
// 감수가 실제로 끝난 글에만 reviewedBy를 넣는다. 감수 예정 상태에서 넣으면 허위 표기다. // 감수가 실제로 끝난 글에만 reviewedBy를 넣는다. 감수 예정 상태에서 넣으면 허위 표기다.
if (reviewer && d.reviewStatus === 'reviewed') { article.reviewedBy = { '@id': `${reviewer.url}#physician` }; if (d.reviewedAt) article.lastReviewed = d.reviewedAt; } if (reviewer && d.reviewStatus === 'reviewed') { article.reviewedBy = { '@id': `${reviewer.url}#physician` }; if (d.reviewedAt) article.lastReviewed = d.reviewedAt; }
if (videos.length) article.video = videos.map((v) => ({ '@type': 'VideoObject', name: v.title, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, url: `https://www.youtube.com/watch?v=${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published ?? d.datePublished, description: v.title, publisher: { '@type': 'Organization', name: '뷰성형외과 VIEW Plastic Surgery (YouTube)' } })); if (videos.length) article.video = videos.map((v) => ({ '@type': 'VideoObject', name: v.title, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, url: `https://www.youtube.com/watch?v=${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published ?? d.datePublished, description: v.title, publisher: { '@type': 'Organization', name: `${surface('youtube').title ?? fact.shortName} (YouTube)` } }));
const graph: any[] = [article, personSchema(d.author, site), clinicSchema(site)]; const graph: any[] = [article, personSchema(d.author, site), clinicSchema(site)];
if (d.reviewer) graph.push(physicianSchema(d.reviewer)); if (d.reviewer) graph.push(physicianSchema(d.reviewer));

View File

@ -2,15 +2,16 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro'; import PostCard from '../../components/PostCard.astro';
import { CATEGORY_LABELS, SITE_NAME } from '../../lib'; import { CATEGORY_LABELS, SITE_NAME, fact, v } from '../../lib';
const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified)); const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified));
const cats = Object.keys(CATEGORY_LABELS); const cats = Object.keys(CATEGORY_LABELS);
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'CollectionPage', '@id': `${site}/posts#page`, name: '전체 글', url: `${site}/posts`, inLanguage: 'ko', publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, hasPart: posts.map((p) => ({ '@type': 'Article', headline: p.data.title, url: `${site}/posts/${p.id}`, dateModified: p.data.dateModified })) }; const ld = { '@type': 'CollectionPage', '@id': `${site}/posts#page`, name: '전체 글', url: `${site}/posts`, inLanguage: 'ko', publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, hasPart: posts.map((p) => ({ '@type': 'Article', headline: p.data.title, url: `${site}/posts/${p.id}`, dateModified: p.data.dateModified })) };
--- ---
<Base title="전체 글" description="뷰성형외과에 대해 자주 묻는 질문과 답을 주제별로 모았습니다." jsonLd={ld}> <Base title="전체 글" description={`${v(fact.shortName, '병원')}에 대해 자주 묻는 질문과 답을 주제별로 모았습니다.`} jsonLd={ld}>
<div class="wrap-wide" style="padding-bottom:4rem"> <div class="wrap-wide" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Archive</div><h1 class="serif">All Answers</h1><p style="color:var(--slate-600);margin-top:0.6rem">궁금한 주제를 골라 보세요. 글 하나가 질문 하나에 답합니다.</p></header> <header class="article-head"><div class="eyebrow">Archive</div><h1 class="serif">All Answers</h1><p style="color:var(--slate-600);margin-top:0.6rem">궁금한 주제를 골라 보세요. 글 하나가 질문 하나에 답합니다.</p></header>
{!posts.length && <p class="sub" style="color:var(--slate-500);margin-top:1.5rem">아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.</p>}
<div class="cat-grid"> <div class="cat-grid">
{cats.map((c) => { {cats.map((c) => {
const list = posts.filter((p) => p.data.category === c); const list = posts.filter((p) => p.data.category === c);

View File

@ -2,43 +2,47 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import VideoLite from '../components/VideoLite.astro'; import VideoLite from '../components/VideoLite.astro';
import data from '../data/videos.json'; import data from '../data/videos.json';
import { SITE_NAME, fact } from '../lib'; import { SITE_NAME, fact, surface, v, has, PENDING } from '../lib';
const clinic = v(fact.shortName, '병원');
const gu = surface('gangnamunni');
type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number }; type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number };
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ch = data.channel; const ch = (data.channel ?? {}) as Record<string, any>;
const topLong = (data.topLong ?? []) as V[]; const topAll = (data.top ?? []) as V[];
const fetchedAt = data.fetchedAt ?? PENDING;
const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString(); const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString();
const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[]; const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[];
const tabs: { key: string; label: string; desc: string; list: V[] }[] = [ const tabs: { key: string; label: string; desc: string; list: V[] }[] = [
{ key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: data.topLong as V[] }, { key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong },
{ key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts }, { key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts },
{ key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: data.top as V[] }, { key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: topAll },
]; ];
const ld = [ const ld = [
{ '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: '뷰성형외과 유튜브 조회수 Top 10', url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: data.fetchedAt }, { '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) },
{ '@type': 'ItemList', '@id': `${site}/videos#list`, name: '뷰성형외과 유튜브 조회수 상위 영상', itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: data.top.length, itemListElement: (data.top as V[]).map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topAll.length, itemListElement: topAll.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) },
]; ];
--- ---
<Base title="의료진 영상 · 뷰성형외과 원장이 직접 설명하는 영상" description={`뷰성형외과 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${data.fetchedAt} 기준.`} jsonLd={ld}> <Base title={`의료진 영상 · ${clinic} 원장이 직접 설명하는 영상`} description={`${clinic} 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${fetchedAt} 기준.`} jsonLd={ld}>
<section class="section dark" style="padding:3.5rem 0 3rem"> <section class="section dark" style="padding:3.5rem 0 3rem">
<div class="wrap-wide"> <div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Videos</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Most Watched</h1><p class="sub">원장이 수술 방법과 안전을 직접 설명하는 영상을 골라 모았습니다. 궁금한 영상을 여기서 바로 재생할 수 있습니다.</p> <div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Videos</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Most Watched</h1><p class="sub">원장이 수술 방법과 안전을 직접 설명하는 영상을 골라 모았습니다. 궁금한 영상을 여기서 바로 재생할 수 있습니다.</p>
<div class="doc-links"> <div class="doc-links">
<a class="doc-link" href="https://www.viewclinic.com/viewis/doctors/" rel="noopener" target="_blank"> <a class="doc-link" href={(fact as any).doctorsUrl || fact.url} rel="noopener" target="_blank">
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"/></svg></span> <span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"/></svg></span>
<span class="txt"><b>의료진 소개</b><small>뷰성형외과 공식 홈페이지</small></span> <span class="txt"><b>의료진 소개</b><small>{clinic} 공식 홈페이지</small></span>
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span> <span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
</a> </a>
<a class="doc-link" href={fact.surfaces.gangnamunni.url} rel="noopener" target="_blank"> <a class="doc-link" href={gu.url || fact.url} rel="noopener" target="_blank">
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 6v6c0 5.3 3.8 10.2 9 11.5C17.2 22.2 21 17.3 21 12V6l-9-4Zm-1 14-3.5-3.5 1.4-1.4L11 13.2l5.1-5.1 1.4 1.4L11 16Z"/></svg></span> <span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 6v6c0 5.3 3.8 10.2 9 11.5C17.2 22.2 21 17.3 21 12V6l-9-4Zm-1 14-3.5-3.5 1.4-1.4L11 13.2l5.1-5.1 1.4 1.4L11 16Z"/></svg></span>
<span class="txt"><b>의료진 {fact.surfaces.gangnamunni.doctors}명</b><small>강남언니 등록 의료진</small></span> <span class="txt"><b>의료진 {v(gu.doctors)}명</b><small>강남언니 등록 의료진</small></span>
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span> <span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
</a> </a>
</div></div> </div></div>
<div class="stats"> <div class="stats">
<div class="stat"><b>{fmt(ch.totalViews)}</b><span class="label">누적 조회수</span><span class="desc">공식 유튜브 채널 영상 {ch.videos.toLocaleString()}편 합계 ({data.fetchedAt} 기준)</span></div> <div class="stat"><b>{has(ch.totalViews) ? fmt(ch.totalViews) : PENDING}</b><span class="label">누적 조회수</span><span class="desc">공식 유튜브 채널 영상 {has(ch.videos) ? ch.videos.toLocaleString() : PENDING}편 합계 ({fetchedAt} 기준)</span></div>
<div class="stat"><b>{ch.subscribers}</b><span class="label">구독자</span><span class="desc"><a href={ch.url} rel="noopener" style="color:var(--purple-300)">{ch.handle}</a></span></div> <div class="stat"><b>{v(ch.subscribers)}</b><span class="label">구독자</span><span class="desc">{has(ch.url) ? <a href={ch.url} rel="noopener" style="color:var(--purple-300)">{ch.handle}</a> : PENDING}</span></div>
<div class="stat"><b>{fmt((data.topLong as V[])[0].views)}</b><span class="label">설명 영상 1위 조회수</span><span class="desc">{(data.topLong as V[])[0].title}</span></div> <div class="stat"><b>{topLong[0] ? fmt(topLong[0].views) : PENDING}</b><span class="label">설명 영상 1위 조회수</span><span class="desc">{topLong[0]?.title ?? '채널 영상 집계 뒤 표시'}</span></div>
<div class="stat"><b>{shorts.length}</b><span class="label">정보형 쇼츠</span><span class="desc">궁금한 것 하나에 1분 안에 답하는 영상. 서포터즈가 답을 글로도 정리했습니다.</span></div> <div class="stat"><b>{shorts.length}</b><span class="label">정보형 쇼츠</span><span class="desc">궁금한 것 하나에 1분 안에 답하는 영상. 서포터즈가 답을 글로도 정리했습니다.</span></div>
</div> </div>
</div> </div>
@ -50,7 +54,8 @@ const ld = [
</div> </div>
{tabs.map((t, i) => ( {tabs.map((t, i) => (
<div class="vpanel" data-panel={t.key} hidden={i !== 0}> <div class="vpanel" data-panel={t.key} hidden={i !== 0}>
<p class="sub" style="color:var(--slate-600);margin:0.8rem 0 1.2rem">{t.desc}. 조회수는 {data.fetchedAt} 기준입니다.</p> <p class="sub" style="color:var(--slate-600);margin:0.8rem 0 1.2rem">{t.desc}. 조회수는 {fetchedAt} 기준입니다.</p>
{!t.list.length && <p class="sub" style="color:var(--slate-500)">채널 영상을 집계한 뒤 싣습니다. {PENDING}.</p>}
<ol class="vlist"> <ol class="vlist">
{t.list.map((v, idx) => ( {t.list.map((v, idx) => (
<li class="vitem"> <li class="vitem">
@ -66,7 +71,7 @@ const ld = [
</ol> </ol>
</div> </div>
))} ))}
<p class="disclosure">영상은 뷰성형외과 공식 유튜브 채널에서 재생됩니다. 조회수·좋아요·댓글 수는 {data.fetchedAt} 기준입니다.</p> <p class="disclosure">영상은 {clinic} 공식 유튜브 채널에서 재생됩니다. 조회수·좋아요·댓글 수는 {fetchedAt} 기준입니다.</p>
</div> </div>
</section> </section>
</Base> </Base>

View File

@ -2,48 +2,46 @@
// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다. // 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다.
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import Gallery from '../components/Gallery.astro'; import Gallery from '../components/Gallery.astro';
import { fact, SITE_NAME, clinicSchema } from '../lib'; import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, ''); const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [ const ld = [
{ '@type': 'WebPage', '@id': `${site}/visit#page`, name: '뷰성형외과 방문 안내', url: `${site}/visit`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, { '@type': 'WebPage', '@id': `${site}/visit#page`, name: `${clinic} 방문 안내`, url: `${site}/visit`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site), clinicSchema(site),
]; ];
--- ---
<Base title="방문 안내 · 예약, 가는 길, 주차, 도착 후 동선" description={`뷰성형외과 방문 준비. 예약 경로 세 가지, 신논현역에서 가는 길, 주차장 진입 경로, 진료시간, 도착 후 몇 층으로 가는지를 순서대로 안내합니다. 주소 ${fact.address.full}.`} jsonLd={ld}> <Base title="방문 안내 · 예약, 가는 길, 주차, 도착 후 동선" description={`${clinic} 방문 준비. 예약 경로, 가는 길, 주차장 진입 경로, 진료시간, 도착 후 동선을 순서대로 안내합니다. 주소 ${v(f.address?.full)}.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem"> <article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Visit</div><h1 class="serif">Plan Your Visit</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">뷰성형외과 방문 안내.</strong> 예약부터 도착 후 동선까지 순서대로 정리했습니다. 주소·전화·진료시간의 근거는 <a href="/clinic">병원 정보</a>에 있습니다.</p></header> <header class="article-head"><div class="eyebrow">Visit</div><h1 class="serif">Plan Your Visit</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">{clinic} 방문 안내.</strong> 예약부터 도착 후 동선까지 순서대로 정리했습니다. 주소·전화·진료시간의 근거는 <a href="/clinic">병원 정보</a>에 있습니다.</p></header>
<h2>1. 예약하기</h2> <h2>1. 예약하기</h2>
<p>상담 예약 경로는 세 가지입니다. 어느 쪽이든 같은 병원 상담센터로 연결됩니다.</p> <p>상담 예약 경로는 세 가지입니다. 어느 쪽이든 같은 병원 상담센터로 연결됩니다.</p>
<div class="table-wrap"><table><thead><tr><th>경로</th><th>방법</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>경로</th><th>방법</th></tr></thead><tbody>
<tr><td>전화</td><td><a href={`tel:${fact.phone.replace(/-/g, '')}`}>{fact.phone}</a></td></tr> <tr><td>전화</td><td>{has(f.phone) ? <a href={`tel:${f.phone.replace(/-/g, '')}`}>{f.phone}</a> : v(null)}</td></tr>
<tr><td>카카오톡 채널</td><td><a href={fact.kakao} rel="noopener">뷰성형외과 상담 채널</a></td></tr> <tr><td>카카오톡 채널</td><td>{has(f.kakao) ? <a href={f.kakao} rel="noopener">{clinic} 상담 채널</a> : v(null)}</td></tr>
<tr><td>홈페이지</td><td><a href={fact.url + '/counsel/reservation/'} rel="noopener">온라인 예약 페이지</a></td></tr> <tr><td>홈페이지</td><td>{has(reservationUrl) ? <a href={reservationUrl} rel="noopener">온라인 예약 페이지</a> : v(null)}</td></tr>
</tbody></table></div> </tbody></table></div>
<h2>2. 진료시간 확인하기</h2> <h2>2. 진료시간 확인하기</h2>
<div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{fact.hours.rows.map((r) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}</tbody></table></div> <div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{(f.hours?.rows ?? []).map((r: any) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}{!(f.hours?.rows ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">출처: <a href={fact.hours.source} rel="noopener">{fact.hours.sourceLabel}</a>. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p> <p style="font-size:0.88rem;color:var(--slate-500)">출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p>
<h2>3. 찾아가기</h2> <h2>3. 찾아가기</h2>
<p><strong>주소</strong> {fact.address.full} (지번 {fact.address.postalNote})</p> <p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p>
<p><strong>지하철</strong> {fact.transit}. 건물 전체가 병원이라 출구에서 바로 보입니다.</p> <p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p>
<p><strong>차로 올 때</strong> {fact.parking}.</p> <p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p>
<Gallery cols={2} items={[ {S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />}
{ src: '/img/clinic/map-1.jpg', alt: '양재·강남역 방향에서 뷰성형외과로 오는 약도. 9호선 신논현역과 병원 위치 표시', caption: '양재·강남역 방향 약도. 출처: 홈페이지 오시는길' },
{ src: '/img/clinic/parking-map.jpg', alt: '뷰성형외과 주차장 진입 경로 약도. 약국 골목으로 진입해 사거리에서 우회전', caption: '주차장 진입 경로. 출처: 홈페이지 오시는길' },
]} />
<h2>4. 도착하면</h2> <h2>4. 도착하면</h2>
<p>먼저 1층 안내데스크로 가면 안내해 줍니다. 상담센터는 4~5층, VIP 상담실은 3층, 수술 전 검진은 지하 1층 검진센터입니다. 층별 구성 전체는 <a href="/clinic">병원 정보</a>에 있습니다.</p> <p>{v(S.arrivalGuide, `도착 후 동선은 병원 확인 뒤 싣습니다. ${v(null)}.`)} 층별 구성 전체는 <a href="/clinic">병원 정보</a>에 있습니다.</p>
<h2>5. 상담 전에 읽어볼 글</h2> <h2>5. 상담 전에 읽어볼 글</h2>
<ul> <ul>
<li><a href="/posts/visit-guide">방문 가이드: 위치·진료시간·주차·예약</a> 대중교통·공항에서 오는 법까지 자세히</li> {(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)}
<li><a href="/posts/safety-system">뷰성형외과 안전 시스템은 무엇으로 이루어져 있나요?</a> 상담에서 확인할 네 가지</li> {!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
<li><a href="/posts/revision-checklist">가슴 재수술을 고민 중이라면 무엇을 먼저 확인해야 하나요?</a> 재수술 상담 준비 체크리스트</li>
</ul> </ul>
<p style="margin-top:2rem"><a class="btn primary" href={fact.url + '/counsel/reservation/'} rel="noopener">뷰성형외과 상담 예약</a></p> <p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p>
<p class="disclosure">{fact.sideEffectNotice}</p> <p class="disclosure">{fact.sideEffectNotice}</p>
</article> </article>
</Base> </Base>

View File

@ -61,6 +61,7 @@ td { color: var(--slate-700); }
.brand { display: flex; align-items: center; gap: 0.6rem; color: var(--primary-900); font-weight: 800; font-size: 1.1rem; } .brand { display: flex; align-items: center; gap: 0.6rem; color: var(--primary-900); font-weight: 800; font-size: 1.1rem; }
.brand .mark { display: none; } .brand .mark { display: none; }
.brand-logo { height: 40px; width: auto; display: block; } .brand-logo { height: 40px; width: auto; display: block; }
.brand-name { height: auto; font-weight: 800; font-size: 1.05rem; color: var(--primary-900); letter-spacing: -0.01em; white-space: nowrap; }
.brand-sep { width: 1px; height: 26px; background: var(--slate-200); margin: 0 0.35rem; } .brand-sep { width: 1px; height: 26px; background: var(--slate-200); margin: 0 0.35rem; }
.brand-text { display: flex; align-items: baseline; gap: 0.45rem; } .brand-text { display: flex; align-items: baseline; gap: 0.45rem; }
.brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; } .brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; }

View File

@ -0,0 +1,2 @@
PUBLIC_INDEXABLE=false
SITE_URL=https://view-supporters-sample.vercel.app

25
templates/supporters-astro/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.vercel

View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

View File

@ -0,0 +1 @@
/Users/haewonkam/orca/workspaces/INFINITH/Productize/supporters/AGENTS.md

View File

@ -0,0 +1,30 @@
# supporters-astro 템플릿
병원 하나의 서포터즈 사이트를 만드는 Astro 7 정적 템플릿이다. `supporters/`(뷰성형외과 샘플)에서 `node scripts/export_template.mjs`로 생성되며, 직접 편집하지 않는다. 고칠 것은 `supporters/`에서 고치고 다시 내보낸다.
## 데이터만 채우면 되는 것
| 파일 | 내용 | 채우는 주체 |
|---|---|---|
| `src/data/factSheet.json` | 병원 사실 단일 원본 (NAP·진료시간·층·진료과·안전·이력·플랫폼 집계) | 근거 수집기 + 병원 확인 |
| `src/data/authors.json` | 작성자(실명·직함·sameAs·정정 이메일), 의학 검토 원장 | 운영자·병원 입력 |
| `src/data/site.json` | 매체 이름·로고·홈 화면 큐레이션(영상 3편, 시설 사진, 방문 약도, 추천 글) | 운영자 |
| `src/data/videos.json` | 유튜브 채널 집계 (Top10 롱폼·정보형 쇼츠+답) | 수집기 |
| `src/data/news.json` | 뉴스룸 (보도자료·기고·언급) | `scripts/build_news.py` |
| `src/data/corrections.json` | 정정 기록 | 운영자 |
| `src/content/posts/*.md` | 글. frontmatter 규격은 `src/content.config.ts` | 글 생성기 + 검토 승인 |
| `public/img/` | 병원 허락을 받은 이미지 | 수집기 |
| `scripts/home_text.txt` | 홈페이지 본문(공백 제거). 40자 연속 일치 검사 기준 | 수집기 |
비어 있는 값은 화면에 "확인 대기"로 표시되고 스키마에서는 빠진다. 지어내지 않는다.
## 빌드·검사
```
npm ci
npm run build # astro build + 발행 게이트(scripts/check.mjs). error가 있으면 exit 1
npm run gate:test # 게이트 규칙 회귀 (픽스처 + 글 전편)
node scripts/check.mjs --update-layout # 룩앤필 변경을 승인한 뒤에만 구조 기준선 갱신
```
배포 주소는 `SITE_URL` 환경변수(vercel.json·astro.config.mjs의 `__CLINIC_ID__`를 병원 id로 교체). 기본은 noindex이며 `PUBLIC_INDEXABLE=true`로 색인을 연다.

View File

@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
// 샘플 배포 주소. 도메인 확정 후 SITE_URL 환경변수로 교체한다.
const site = process.env.SITE_URL || 'https://supporters-__CLINIC_ID__.vercel.app';
export default defineConfig({
site,
trailingSlash: 'never',
build: { format: 'file' },
compressHTML: true,
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "supporters-template",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build && node scripts/check.mjs",
"preview": "astro preview",
"astro": "astro",
"check": "node scripts/gate/test.mjs && node scripts/check.mjs",
"gate:test": "node scripts/gate/test.mjs",
"export:template": "node scripts/export_template.mjs"
},
"dependencies": {
"astro": "^7.3.1"
},
"allowScripts": {
"esbuild": true
},
"devDependencies": {
"js-yaml": "^4.3.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""뉴스룸 데이터 빌더. 병원 언론보도 게시판 크롤(board_news.json) + 네이버 뉴스 API 수집(naver_news_dated.json) → src/data/news.json
사용: python3 scripts/build_news.py <scratch_dir>"""
import json,re,sys,collections,urllib.parse
S=sys.argv[1]
board=json.load(open(f"{S}/board_news.json")); naver=json.load(open(f"{S}/naver_news_dated.json"))
OUTLET={"mdtoday.co.kr":"메디컬투데이","jeonmin.co.kr":"전민일보","moneyweek.co.kr":"머니S","ajunews.com":"아주경제","rapportian.com":"라포르시안","n.news.naver.com":"네이버뉴스","health.cbs.co.kr":"노컷뉴스","newsis.com":"뉴시스","biz.heraldcorp.com":"헤럴드경제","heraldcorp.com":"헤럴드경제","eroun.net":"이로운넷","hidoc.co.kr":"하이닥","sportsworldi.com":"스포츠월드","newsway.co.kr":"뉴스웨이","koreadaily.com":"미주중앙일보","sports.chosun.com":"스포츠조선","bokuennews.com":"보건뉴스","medisobizanews.com":"메디소비자뉴스","megaeconomy.co.kr":"메가경제","newspim.com":"뉴스핌","wowtv.co.kr":"한국경제TV","wowstar.wowtv.co.kr":"한국경제TV 와우스타","etoday.co.kr":"이투데이","medipana.com":"메디파나뉴스","view.asiae.co.kr":"아시아경제","asiae.co.kr":"아시아경제","jemin.com":"제민일보","newstown.co.kr":"뉴스타운","sports.hankooki.com":"스포츠한국","hankooki.com":"한국일보","psychiatricnews.net":"정신의학신문","mt.co.kr":"머니투데이","siminilbo.co.kr":"시민일보","hemophilia.co.kr":"헤모필리아라이프","beopbo.com":"법보신문","stardailynews.co.kr":"스타데일리뉴스","thefirstmedia.net":"더퍼스트미디어","edu.donga.com":"에듀동아","ikoreanspirit.com":"코리안스피릿","biotimes.co.kr":"바이오타임즈","gosiweek.com":"고시위크","livesnews.com":"라이브뉴스","vegannews.co.kr":"비건뉴스","sisunnews.co.kr":"시선뉴스","dhnews.co.kr":"대학저널","yna.co.kr":"연합뉴스","news.kbs.co.kr":"KBS","news.sbs.co.kr":"SBS","biz.sbs.co.kr":"SBS Biz","imnews.imbc.com":"MBC","donga.com":"동아일보","hani.co.kr":"한겨레","khan.co.kr":"경향신문","chosun.com":"조선일보","biz.chosun.com":"조선비즈","hankookilbo.com":"한국일보","mbn.mk.co.kr":"MBN","mk.co.kr":"매일경제","yonhapnewstv.co.kr":"연합뉴스TV","news.tvchosun.com":"TV조선","sisajournal.com":"시사저널","kizmom.com":"키즈맘","idsn.co.kr":"아이디에스엔","e-science.co.kr":"이사이언스","asiatoday.co.kr":"아시아투데이","newsworks.co.kr":"뉴스웍스","gukjenews.com":"국제뉴스","cstimes.com":"컨슈머타임스","cnbnews.com":"CNB뉴스","enewstoday.co.kr":"이뉴스투데이","fntoday.co.kr":"파이낸스투데이","seoul.co.kr":"서울신문","queen.co.kr":"퀸","medicalworldnews.co.kr":"메디컬월드뉴스","thereport.co.kr":"더리포트","getnews.co.kr":"게잇뉴스","newslock.co.kr":"뉴스락","dailypop.kr":"데일리팝","greened.kr":"녹색경제신문","public25.com":"퍼블릭뉴스","news.einfomax.co.kr":"연합인포맥스","safetimes.co.kr":"세이프타임즈","pennmike.com":"펜앤드마이크","health.chosun.com":"헬스조선","kormedi.com":"코메디닷컴","sportsseoul.com":"스포츠서울","segye.com":"세계일보","sedaily.com":"서울경제","fnnews.com":"파이낸셜뉴스","ytn.co.kr":"YTN","news1.kr":"뉴스1","newsen.com":"뉴스엔","osen.co.kr":"OSEN","xportsnews.com":"엑스포츠뉴스","tvdaily.co.kr":"티브이데일리","topstarnews.net":"톱스타뉴스","hankyung.com":"한국경제","dailian.co.kr":"데일리안","kukinews.com":"쿠키뉴스","joongang.co.kr":"중앙일보","dt.co.kr":"디지털타임스","kyeonggi.com":"경기일보","incheonilbo.com":"인천일보","ggilbo.com":"금강일보","kwnews.co.kr":"강원일보","kmib.co.kr":"국민일보","munhwa.com":"문화일보","edaily.co.kr":"이데일리","nocutnews.co.kr":"노컷뉴스","news.mt.co.kr":"머니투데이","newsprime.co.kr":"프라임경제","ilyo.co.kr":"일요신문","sports.donga.com":"스포츠동아","medicaltimes.com":"메디칼타임즈","docdocdoc.co.kr":"청년의사","whosaeng.com":"후생신보","bosa.co.kr":"의학신문","dailymedi.com":"데일리메디","akomnews.com":"한의신문","joynews24.com":"조이뉴스24","inews24.com":"아이뉴스24","zdnet.co.kr":"지디넷코리아","ceoscoredaily.com":"CEO스코어데일리","viva100.com":"브릿지경제","g-enews.com":"글로벌이코노믹","thebell.co.kr":"더벨","businesspost.co.kr":"비즈니스포스트","ohmynews.com":"오마이뉴스","pressian.com":"프레시안","mediatoday.co.kr":"미디어오늘","nate.com":"네이트뉴스","news.nate.com":"네이트뉴스","daum.net":"다음뉴스","v.daum.net":"다음뉴스","news.zum.com":"줌뉴스"}
def outlet_of(url):
h=urllib.parse.urlparse(url).netloc.replace("www.","")
if h in OUTLET: return OUTLET[h]
for k,v in OUTLET.items():
if h.endswith(k): return v
return h or "출처 미상"
NEG=re.compile(r"공정위|제재|시정명령|뒷광고|적발|철퇴|덜미|기만|소송|사망|사고\b|경찰|검찰|논란|피소|의료사고|압수|내돈내산|조작|가짜|대본|허위|과장\s*광고|벌금|징계|영업정지|고발|수사|불법")
TOPICS=[("가슴성형",r"가슴|유방|보형물|모티바|멘토|프리저베|맘모톰"),("안면윤곽·양악",r"윤곽|양악|광대|사각턱|턱|돌출입"),("눈·코성형",r"쌍꺼풀|눈매|눈성형|눈밑|안검|코성형|코재수술|콧|비중격|기능코"),("리프팅·피부·안티에이징",r"리프팅|거상|주름|피부|울쎄라|써마지|리쥬란|보톡스|필러|줄기세포|안티에이징|온다"),("지방·바디",r"지방|복부|바디|체형"),("모발·남성",r"모발|탈모|남자|남성"),("수상·인증",r"수상|어워드|표창|인증|선정|위촉|KOL|1위"),("행사·교육·협약",r"심포지엄|세미나|학회|교육|견학|협약|MOU|박람회|개막|참여|참가|초청|심사위원|강연|발표"),("해외·의료관광",r"해외|외국인|의료관광|글로벌|중국|베트남|인도네시아|몽골|태국|일본|러시아|K-뷰티"),("안전·시스템",r"안전|마취|CCTV|검진|무사고|실명제|응급|회복|사후관리|애프터케어|방역|고압산소"),("사회공헌",r"기부|후원|봉사|나눔|장학|기탁"),("방송·렛미인",r"렛미인|방송|TV|출연|건강한의사")]
def topic_of(t):
for name,rx in TOPICS:
if re.search(rx,t): return name
return "기타"
def norm(t): return re.sub(r"[\s\W]+","",re.sub(r"^\[[^\]]+\]\s*","",t)).lower()
REL=re.compile(r"뷰\s?성형외과(의원)?(\s*(안티에이징센터|피부과))?\s*[,:·\-–—]|뷰\s?성형외과(의원)?\s*[가-힣]{2,4}\s*(대표)?원장|뷰\s?성형외과(의원)?.*(도입|진행|실시|이수|참여|선정|수상|위촉|개최|강화|출시|후원|기부|협약|체결|발표|공개|오픈|운영|확대|인터뷰|노하우|전수)|\[뷰성형외과\]")
Q1=re.compile(r"뷰\s?성형외과(의원)?\s*(안티에이징센터\s*|피부과\s*)?[가-힣]{2,4}\s*(대표)?원장"); Q2=re.compile(r"[가-힣]{2,4}\s*뷰\s?성형외과(의원)?\s*(대표)?원장|뷰\s?성형외과(의원)?\s*(대표)?원장|뷰\s?성형외과(의원)?\s*(성형외과\s*)?전문의")
out=[];seen_t=set();seen_u=set();stats=collections.Counter()
for b in board:
t=(b.get("title") or "").strip()
if not b.get("date") or not t: stats["board_skipped"]+=1; continue
m=re.match(r"^\[([^\]]+)\]\s*(.*)$",t)
outlet=m.group(1).strip() if m else (outlet_of(b["source"]) if b.get("source") else "뷰성형외과 언론보도 게시판")
title=m.group(2).strip() if m else t
url=b.get("source") or f"https://www.viewclinic.com/board/media/news/?uid={b['uid']}&mod=document"
k=norm(title)
if k in seen_t or url in seen_u: stats["board_dup"]+=1; continue
seen_t.add(k); seen_u.add(url)
kind="release" if REL.search(title) else ("column" if (Q1.search(title) or Q2.search(title)) else "release") # 게시판 = 병원 배포. 기고형이면 column
if kind=="release" and not re.search(r"뷰\s?성형외과",title): kind="column" # 시술 설명형 기고
out.append({"date":b["date"],"title":title,"outlet":outlet,"url":url,"kind":kind,"origin":"board","topic":topic_of(title),"boardUid":b["uid"]})
for n in naver:
t=n["title"]; d=n["desc"]; url=n["originallink"] or n["link"]; k=norm(t)
if NEG.search(t): stats["excluded_thirdparty"]+=1; continue
if k in seen_t or url in seen_u: stats["dup"]+=1; continue
txt=t+" "+d
kind="release" if REL.search(t) else ("column" if (Q1.search(txt) or Q2.search(txt)) else "mention")
seen_t.add(k); seen_u.add(url); stats[kind]+=1
out.append({"date":n["date"],"title":t,"outlet":outlet_of(url),"url":url,"kind":kind,"origin":"naver","topic":topic_of(txt)})
out.sort(key=lambda x:x["date"],reverse=True)
json.dump({"generatedAt":"2026-09-04","sources":{"board":"https://www.viewclinic.com/board/media/news/","naver":"Naver Search API news, 2026-09-04"},"items":out},open("src/data/news.json","w"),ensure_ascii=False,indent=1)
print("stats",dict(stats),"final",len(out))
print("kinds",collections.Counter(i["kind"] for i in out),"origin",collections.Counter(i["origin"] for i in out))
print("years",sorted(collections.Counter(i["date"][:4] for i in out).items()))

View File

@ -0,0 +1,102 @@
// 발행 게이트 러너. 빌드 결과(dist)와 소스(글 frontmatter·데이터)를 검사해 error가 하나라도 있으면 배포를 막는다.
// 규칙 본체는 scripts/gate/rules.mjs. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3.
//
// node scripts/check.mjs 검사
// node scripts/check.mjs --update-layout 구조 기준선(§3-2) 갱신. 룩앤필 변경을 haewon이 승인한 뒤에만
// node scripts/check.mjs --today=YYYY-MM-DD
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as R from './gate/rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const DIST = here('../dist/');
const POSTS = here('../src/content/posts/');
const LAYOUT_BASELINE = here('./gate/layout-baseline.json');
const args = process.argv.slice(2);
const updateLayout = args.includes('--update-layout');
const today = args.find((a) => a.startsWith('--today='))?.slice(8) ?? new Date().toISOString().slice(0, 10);
const HOME_TEXT = existsSync(here('./home_text.txt')) ? readFileSync(here('./home_text.txt'), 'utf8') : '';
const authors = JSON.parse(readFileSync(here('../src/data/authors.json'), 'utf8'));
const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8'));
const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8'));
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
// 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙).
const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set' };
function walk(dir, out = []) {
for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); }
return out;
}
const findings = []; // { where, level, code, msg }
const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }));
// ---------- 1. dist 검사 ----------
if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); }
const files = walk(DIST);
const firstPost = files.map((f) => f.replace(DIST, '/')).filter((r) => r.startsWith('/posts/')).sort()[0];
if (firstPost) LAYOUT_PAGES[firstPost] = 'sequence';
const layoutNow = {};
for (const f of files) {
const html = readFileSync(f, 'utf8');
const rel = f.replace(DIST, '/');
const text = R.stripHtml(html);
// JSON-LD
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => m[1]);
for (const b of blocks) {
try {
const types = JSON.stringify(JSON.parse(b));
if (rel.startsWith('/posts/') && !/FAQPage|Article/.test(types)) add(rel, [{ level: R.E, code: 'SCHEMA_MISSING', msg: 'Article/FAQPage 스키마 없음' }]);
} catch (e) { add(rel, [{ level: R.E, code: 'JSONLD_PARSE', msg: `JSON-LD 파싱 실패: ${e.message}` }]); }
}
if (!blocks.length && !/404/.test(rel)) add(rel, [{ level: R.E, code: 'JSONLD_MISSING', msg: 'JSON-LD 없음' }]);
// alt
for (const img of html.matchAll(/<img\b[^>]*>/g)) if (!/\balt="[^"]+"/.test(img[0])) add(rel, [{ level: R.E, code: 'IMG_ALT', msg: `alt 없는 이미지: ${img[0].slice(0, 80)}` }]);
// H1
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) add(rel, [{ level: R.E, code: 'H1_COUNT', msg: `H1 ${h1}개 (1개여야 함)` }]);
// 금칙 표현·홈페이지 중복·운영자 어휘·제목 화법
add(rel, R.checkBannedBody(text));
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
add(rel, R.checkOperatorVocab(text, rel));
add(rel, R.checkHeadingEndings(html));
// 구조 지문
if (LAYOUT_PAGES[rel]) layoutNow[rel] = R.layoutFingerprint(html);
}
// §3-2 구조 기준선
if (updateLayout) {
writeFileSync(LAYOUT_BASELINE, JSON.stringify(layoutNow, null, 1));
console.log(`구조 기준선 갱신: ${Object.keys(layoutNow).join(', ')}`);
} else {
const base = existsSync(LAYOUT_BASELINE) ? JSON.parse(readFileSync(LAYOUT_BASELINE, 'utf8')) : {};
for (const [rel, mode] of Object.entries(LAYOUT_PAGES)) if (layoutNow[rel]) add(rel, R.checkLayout(layoutNow[rel], base[rel], rel, { mode }));
}
// ---------- 2. 소스 검사 (글 frontmatter·본문) ----------
const posts = readdirSync(POSTS).filter((f) => f.endsWith('.md'));
for (const f of posts) {
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
add(`posts/${f}`, R.checkPostSource(parsed, { physicians: authors.physicians, supporters: authors.supporters }));
}
// ---------- 3. 데이터 검사 ----------
add('data/factSheet.json', R.checkNextCheck(fact, today));
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
// "채널 인기 전체" 탭은 제거 여부가 haewon 결정 대기(v2 §9-1). 결정 전까지 경고만.
add('data/videos.json', R.checkVideoTitles(videos.top, { level: R.W, label: '채널 인기 전체 탭(결정 대기)' }));
// ---------- 출력 ----------
const errors = findings.filter((x) => x.level === R.E);
const warns = findings.filter((x) => x.level === R.W);
const byCode = (list) => Object.entries(list.reduce((m, x) => ((m[x.code] = (m[x.code] ?? 0) + 1), m), {})).map(([k, v]) => `${k}×${v}`).join(', ');
for (const x of errors) console.error(` ✗ ${x.where}: [${x.code}] ${x.msg}`);
const shownWarn = warns.slice(0, 40);
for (const x of shownWarn) console.warn(` △ ${x.where}: [${x.code}] ${x.msg}`);
if (warns.length > shownWarn.length) console.warn(` △ … 경고 ${warns.length - shownWarn.length}건 더 있음`);
console.log(`검사 파일 ${files.length}개 · 글 ${posts.length}편 · 오류 ${errors.length}건${errors.length ? ` (${byCode(errors)})` : ''} · 경고 ${warns.length}건${warns.length ? ` (${byCode(warns)})` : ''}`);
if (errors.length) process.exit(1);

View File

@ -0,0 +1,52 @@
// supporters/(뷰성형외과 샘플) → templates/supporters-astro/ 내보내기.
// 병원 고유 데이터(글·데이터 JSON·이미지·home_text·구조 기준선)를 빼고 빈 데이터 세트(scripts/template/data)를 넣는다.
// 멱등: 대상 폴더를 지우고 다시 만든다.
// node scripts/export_template.mjs [--dest ../templates/supporters-astro]
import { cpSync, rmSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const SRC = fileURLToPath(new URL('../', import.meta.url)).replace(/\/$/, '');
const argDest = process.argv.indexOf('--dest');
const DEST = argDest > -1 ? process.argv[argDest + 1] : join(SRC, '..', 'templates', 'supporters-astro');
const TPL = join(SRC, 'scripts', 'template');
// 복사에서 뺄 경로 (SRC 기준 상대). 디렉터리는 하위 전부.
const EXCLUDE = [
'node_modules', 'dist', '.astro', '.vercel', 'docs',
'src/content/posts', 'public/img', 'scripts/home_text.txt', 'scripts/gate/layout-baseline.json', 'scripts/template', 'README.md',
];
const excluded = (abs) => {
const rel = relative(SRC, abs).split(sep).join('/');
return EXCLUDE.some((e) => rel === e || rel.startsWith(e + '/'));
};
rmSync(DEST, { recursive: true, force: true });
mkdirSync(DEST, { recursive: true });
cpSync(SRC, DEST, { recursive: true, filter: (s) => !excluded(s) });
// 빈 데이터 세트
for (const f of readdirSync(join(TPL, 'data'))) cpSync(join(TPL, 'data', f), join(DEST, 'src', 'data', f));
mkdirSync(join(DEST, 'src', 'content', 'posts'), { recursive: true });
writeFileSync(join(DEST, 'src', 'content', 'posts', '.gitkeep'), '');
mkdirSync(join(DEST, 'public', 'img'), { recursive: true });
writeFileSync(join(DEST, 'public', 'img', '.gitkeep'), '');
writeFileSync(join(DEST, 'scripts', 'home_text.txt'), '');
cpSync(join(TPL, 'README.template.md'), join(DEST, 'README.md'));
// 배포 주소 자리표시자
const sub = (rel, from, to) => { const p = join(DEST, rel); const s = readFileSync(p, 'utf8'); if (!s.includes(from)) throw new Error(`${rel}: "${from}" 없음`); writeFileSync(p, s.split(from).join(to)); };
sub('astro.config.mjs', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app');
sub('vercel.json', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app');
const pkgPath = join(DEST, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
pkg.name = 'supporters-template';
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
// 남은 병원 고유 문자열 검사
const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/;
const leaks = [];
const walk = (d) => { for (const f of readdirSync(d, { withFileTypes: true })) { const p = join(d, f.name); if (f.isDirectory()) { if (f.name !== 'node_modules') walk(p); } else if (/\.(astro|ts|mjs|json|css)$/.test(f.name) && !p.endsWith('export_template.mjs') && !p.includes('/scripts/gate/fixtures/') && CLINIC_RE.test(readFileSync(p, 'utf8'))) leaks.push(relative(DEST, p)); } };
walk(DEST);
if (leaks.length) { console.error('병원 고유 문자열이 남은 파일:\n ' + leaks.join('\n ')); process.exit(1); }
console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`);

View File

@ -0,0 +1,5 @@
{
"_comment": "픽스처 전용 작성자·검토자. 사이트의 authors.json과 무관하게 테스트가 돌도록 한다.",
"supporters": { "ansm": { "name": "픽스처 편집자", "role": "편집 책임", "email": "", "sameAs": [] } },
"physicians": { "dr-choi": { "name": "픽스처 원장", "title": "원장 · 성형외과 전문의", "credentials": [], "url": "https://example.com/dr", "sameAs": [] } }
}

View File

@ -0,0 +1,15 @@
# 픽스처 공통 frontmatter. test.mjs가 각 픽스처의 frontmatter와 병합한다.
title: "픽스처 글입니다"
category: D
categoryLabel: 시술 정보
qbIds: [D-01]
summary:
- "첫 번째 요약입니다. 영상 사례 기준입니다."
- "두 번째 요약입니다. 상담에서 확인하세요."
description: "픽스처"
author: ansm
reviewStatus: pending
datePublished: "2026-09-07"
dateModified: "2026-09-07"
sources:
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07", type: clinic }

View File

@ -0,0 +1,12 @@
---
expect: [CONCLUSION_PHRASE]
---
## 결론
네 가지 질문에 답하면 안전 체계가 실제로 돌아가고 있다고 볼 수 있습니다. 수술 후 다음 날 출근할 수 있을 정도로 회복이 빠릅니다.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,2 @@
<!-- expect: (none) -->
<body><main><h1>상담 전 질문</h1><h2>답하는 방식</h2><p>원장 설명 영상과 병원 공개 자료를 근거로 답합니다.</p></main></body>

View File

@ -0,0 +1,16 @@
---
expect: [NUMBER_DATE_MISMATCH]
summary:
- "강남언니 후기 19,373건 (2026-09-04 확인) 입니다."
- "상담에서 확인하세요."
faq:
- q: "후기는 몇 건인가요?"
a: "강남언니 기준 19,373건입니다 (8월 28일 확인)."
---
## 표
| 플랫폼 | 후기 수 | 확인일 |
|---|---|---|
| 강남언니 | 19,373건 | 2026-09-04 |
플랫폼마다 확인일이 다릅니다. 상담에서 확인하세요.

View File

@ -0,0 +1,2 @@
<!-- expect: OPERATOR_VOCAB -->
<body><main><h1>서포터즈 소개</h1><p>이 글은 답변엔진이 인용하는 단락으로 구성했습니다. 질문 뱅크 120문항 중 QB D 카테고리입니다.</p></main></body>

View File

@ -0,0 +1,12 @@
---
expect: [REGULATION_SOURCE_MISSING]
---
## 승인
이 보형물은 2024년 미국 FDA 승인을 받았습니다.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [REVIEWED_WITHOUT_DATE]
reviewer: dr-choi
reviewStatus: reviewed
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [REVIEWER_UNKNOWN]
reviewer: dr-nobody
reviewStatus: pending
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [SOURCE_TYPE_MISSING]
sources:
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07" }
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [BANNED_VIDEO_TITLE]
videos:
- { id: abc123, title: "코성형+지방이식 전후 #Shorts", published: "2024-01-01" }
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,270 @@
// 발행 게이트 규칙. 전부 순수 함수다. 입력은 문자열·객체, 출력은 { level, code, msg }[] 이다.
// level: 'error' = 배포 차단, 'warn' = 표시만. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3.
import yaml from 'js-yaml';
export const E = 'error';
export const W = 'warn';
const r = (level, code, msg) => ({ level, code, msg });
// ---------- 공통 유틸 ----------
export function stripHtml(html) {
return html
.replace(/<script[\s\S]*?<\/script>/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, ' ');
}
export function sentences(text) {
return text
.split(/(?<=[.!?。])\s+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
}
export function paragraphs(text) {
return text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
}
/** md 파일 → { data, body }. YAML frontmatter는 js-yaml로 읽는다. */
export function parseMarkdown(src) {
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) return { data: {}, body: src };
return { data: yaml.load(m[1]) ?? {}, body: m[2] };
}
/** 글에서 검사 대상 텍스트 필드만 모은다. history(갱신 기록)는 규칙을 설명하는 문장이 들어가므로 제외한다. */
export function postTextFields(data, body) {
const summary = (data.summary ?? []).map((s) => ({ field: 'summary', text: String(s) }));
const faq = (data.faq ?? []).flatMap((f) => [{ field: 'faq.q', text: f.q }, { field: 'faq.a', text: f.a }]);
return [...summary, { field: 'body', text: body }, ...faq];
}
// ---------- §3-1 고객 화면과 운영자 설명의 분리 ----------
export const OPERATOR_VOCAB = /답변엔진|AI 인용|AEO|GEO|질문 뱅크|QB [A-G]|실측|단일 원본|수집 기준|API 집계|임베드|복제|의료법 제\d+조|롱폼|쇼츠 분류/g;
export const OPERATOR_EXEMPT = [/^\/editorial\.html$/, /^\/404\.html$/];
export function checkOperatorVocab(text, rel = '') {
if (OPERATOR_EXEMPT.some((re) => re.test(rel))) return [];
const hits = [...new Set((text.match(OPERATOR_VOCAB) ?? []))];
return hits.map((h) => r(E, 'OPERATOR_VOCAB', `고객 화면에 운영자 어휘: "${h}"`));
}
// ---------- §3-2 확정 디자인의 보존 (구조 지문) ----------
/** 텍스트를 제거하고 body 안 요소의 tag.class 순서만 남긴다. 텍스트만 바꿨으면 지문이 같다. */
export function layoutFingerprint(html) {
const body = html.match(/<body[^>]*>([\s\S]*)<\/body>/)?.[1] ?? html;
const tokens = [];
for (const m of body.matchAll(/<([a-z][a-z0-9-]*)\b([^>]*)>/g)) {
const tag = m[1];
if (['script', 'style', 'link', 'meta', 'br', 'path', 'svg'].includes(tag)) continue;
const cls = m[2].match(/\bclass="([^"]*)"/)?.[1]?.trim().split(/\s+/).sort().join('.') ?? '';
tokens.push(cls ? `${tag}.${cls}` : tag);
}
return tokens;
}
export function checkLayout(current, baseline, rel, { mode = 'sequence' } = {}) {
if (!baseline) return [r(W, 'LAYOUT_NO_BASELINE', `구조 기준선 없음 (node scripts/check.mjs --update-layout 로 생성)`)];
if (mode === 'set') {
const a = new Set(current), b = new Set(baseline);
const added = [...a].filter((x) => !b.has(x)), removed = [...b].filter((x) => !a.has(x));
if (!added.length && !removed.length) return [];
return [r(W, 'LAYOUT_CHANGED', `${rel} 구조 변경 (추가 ${added.length}, 제거 ${removed.length}): ${[...added.slice(0, 3), ...removed.slice(0, 3).map((x) => '-' + x)].join(', ')}`)];
}
if (current.length === baseline.length && current.every((t, i) => t === baseline[i])) return [];
let i = 0; while (i < current.length && i < baseline.length && current[i] === baseline[i]) i++;
return [r(W, 'LAYOUT_CHANGED', `${rel} 구조 변경 (기준 ${baseline.length} → 현재 ${current.length} 요소, 첫 차이 #${i}: "${baseline[i] ?? '∅'}" → "${current[i] ?? '∅'}"). 룩앤필 변경은 haewon 승인 후 --update-layout`)];
}
// ---------- §3-3 한국어 화법 ----------
export const HEADING_VERB_END = /(합니다|하지 않습니다|밝힙니다)\s*$/;
export function checkHeadingEndings(html) {
const out = [];
for (const m of html.matchAll(/<h[123]\b[^>]*>([\s\S]*?)<\/h[123]>/g)) {
const t = stripHtml(m[1]).trim();
if (HEADING_VERB_END.test(t)) out.push(r(W, 'HEADING_VERB', `제목이 동사 종결 (명사구로): "${t}"`));
}
return out;
}
/** 반말 서술("채운다.", "있다.") 검출. 존댓말(니다.)과 개조식 명사 종결은 제외. 경고 수준. */
export const PLAIN_ENDING = /[가-힣](?<!니)다\.(?=\s|$)/;
export function checkPlainEndings(text, field = '') {
const hits = sentences(text).filter((s) => PLAIN_ENDING.test(s));
if (!hits.length) return [];
return [r(W, 'PLAIN_ENDING', `${field ? field + ': ' : ''}반말 서술 ${hits.length}문장 (예: "${hits[0].slice(0, 60)}")`)];
}
// ---------- §3-4 근거 유형과 원문 링크 ----------
export const SOURCE_TYPES = ['clinic', 'doctor', 'product', 'regulation', 'platform', 'secondary'];
export function checkSourcesType(data) {
const out = [];
(data.sources ?? []).forEach((s, i) => {
if (!s.type) out.push(r(E, 'SOURCE_TYPE_MISSING', `sources[${i}] type 없음: "${s.label ?? s.url}"`));
else if (!SOURCE_TYPES.includes(s.type)) out.push(r(E, 'SOURCE_TYPE_UNKNOWN', `sources[${i}] type "${s.type}" 은 허용 목록 밖`));
});
return out;
}
/** FDA·ISO·승인·연구 주장이 있으면 regulation 타입 원문 소스가 있어야 한다. 학회명(연구회·연구소)은 제외. */
export const REGULATION_CLAIM = /FDA|ISO\s?\d|승인|연구(?!회|소|원|팀|실)/;
export function checkRegulationSource(data, fields) {
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
if (hasReg) return [];
const hit = fields.map((f) => f.text.match(REGULATION_CLAIM)?.[0]).find(Boolean);
return hit ? [r(E, 'REGULATION_SOURCE_MISSING', `본문에 "${hit}" 주장이 있는데 regulation 타입 원문 소스가 없음`)] : [];
}
// ---------- §3-5 근거 범위 표현 ----------
const NEGATION = /아닙니다|아니다|아니며|아니고|않습니다|않는다|않으며|않아|없습니다|뜻은 아|것은 아/;
export const CONCLUSION_PATTERNS = [
{ re: /안정성이 확인/, why: '안정성 확인을 단정' },
{ re: /안전이 보장/, why: '안전 보장을 단정' },
{ re: /안전하다고 볼 수/, why: '안전 결론' },
{ re: /돌아가고 있다고 볼 수/, why: '운영 실태 결론' },
{ re: /장점을 합친/, why: '제품 비교 단정' },
{ re: /비교우위/, why: '비교우위 단정', allowIf: /원장|임상|의견|판단/ },
{ re: /다음\s*날 출근할 수 있을 정도/, why: '회복 기대치 일반화' },
{ re: /(효과|결과)(를|가) 보장/, why: '효과 보장' },
];
export function checkConclusions(fields) {
const out = [];
for (const { field, text } of fields) {
for (const s of sentences(text)) {
for (const p of CONCLUSION_PATTERNS) {
if (!p.re.test(s)) continue;
if (NEGATION.test(s)) continue;
if (p.allowIf && p.allowIf.test(s)) continue;
out.push(r(E, 'CONCLUSION_PHRASE', `${field}: ${p.why} "${s.slice(0, 70)}"`));
}
}
}
return out;
}
/** 시간·기간 수치가 있는 문단에 개인차·상담·영상·사례 한정이 없으면 경고. 날짜(9월 4일, 2026-09-04)는 제외. */
export const DURATION_NUM = /(?<![\d월\-])\d+\s*(분|일|주|개월)(?![\d])/;
export const QUALIFIER = /개인차|상담|영상|사례|기준|홈페이지|주의사항|애프터케어|병원 안내|안내|자료에 없음|병원 확인|확인 대기|도보|걸어서/;
export function checkNumericContext(fields) {
const out = [];
for (const { field, text } of fields) {
const ps = paragraphs(text);
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
if (!DURATION_NUM.test(p)) continue;
// 표는 바로 다음 문단(해설)과 묶어서 본다
const unit = /^\|/.test(p) && ps[i + 1] ? p + '\n' + ps[i + 1] : p;
if (QUALIFIER.test(unit)) continue;
out.push(r(W, 'NUMERIC_NO_QUALIFIER', `${field}: 수치 문단에 개인차·상담·영상·사례 한정 없음 "${p.replace(/\s+/g, ' ').slice(0, 60)}"`));
}
}
return out;
}
// ---------- §3-6 수치·확인일 일관성 ----------
const DATE_RE = /(\d{4})-(\d{2})(?:-(\d{2}))?|(?:(\d{4})년\s*)?(\d{1,2})월(?:\s*(\d{1,2})일)?/g;
function normDate(m) {
if (m[1]) return { y: m[1], mo: m[2], d: m[3] ?? null };
return { y: m[4] ?? null, mo: String(m[5]).padStart(2, '0'), d: m[6] ? String(m[6]).padStart(2, '0') : null };
}
function dateConflict(a, b) {
if (a.mo !== b.mo) return true;
if (a.y && b.y && a.y !== b.y) return true;
if (a.d && b.d && a.d !== b.d) return true;
return false;
}
const fmtDate = (d) => `${d.y ?? '????'}-${d.mo}${d.d ? '-' + d.d : ''}`;
export function checkNumberDates(fields) {
const seen = new Map(); // number → { date, field }
const out = [];
for (const { field, text } of fields) {
for (const s of sentences(text)) {
const dates = [...s.matchAll(DATE_RE)].map((m) => ({ idx: m.index, d: normDate(m) }));
if (!dates.length) continue;
for (const n of s.matchAll(/\d{1,3}(?:,\d{3})+/g)) {
// 가장 가까운 날짜를 이 수치의 확인일로 본다
const near = dates.reduce((best, x) => (Math.abs(x.idx - n.index) < Math.abs(best.idx - n.index) ? x : best));
const prev = seen.get(n[0]);
if (prev && dateConflict(prev.d, near.d)) {
out.push(r(E, 'NUMBER_DATE_MISMATCH', `수치 ${n[0]} 의 확인일이 다름: ${fmtDate(prev.d)} (${prev.field}) vs ${fmtDate(near.d)} (${field})`));
} else if (!prev) seen.set(n[0], { d: near.d, field });
}
}
}
return out;
}
export function checkNextCheck(fact, today) {
if (!fact?.nextCheck) return [r(W, 'FACT_NEXTCHECK_MISSING', 'factSheet.nextCheck 없음')];
return fact.nextCheck < today ? [r(W, 'FACT_NEXTCHECK_PASSED', `factSheet 다음 확인일(${fact.nextCheck}) 경과. 플랫폼 집계를 재확인할 것`)] : [];
}
// ---------- §3-7 작성자·검토자·검토일 ----------
export function checkReview(data, physicians = {}, supporters = {}) {
const out = [];
if (data.author && !supporters[data.author]) out.push(r(E, 'AUTHOR_UNKNOWN', `author "${data.author}" 가 authors.supporters 에 없음`));
if (data.reviewer && !physicians[data.reviewer]) out.push(r(E, 'REVIEWER_UNKNOWN', `reviewer "${data.reviewer}" 가 authors.physicians 에 없음`));
if (data.reviewStatus === 'reviewed') {
if (!data.reviewer) out.push(r(E, 'REVIEWED_WITHOUT_REVIEWER', 'reviewStatus=reviewed 인데 reviewer 없음'));
if (!data.reviewedAt) out.push(r(E, 'REVIEWED_WITHOUT_DATE', 'reviewStatus=reviewed 인데 reviewedAt 없음'));
}
return out;
}
// ---------- §3-8 싣지 않는 것 ----------
export const BANNED_VIDEO_TITLE = /전후|리뷰|성공|번호따|변신|역대급/;
export function checkVideoTitles(list, { level = E, label = '' } = {}) {
return (list ?? [])
.filter((v) => BANNED_VIDEO_TITLE.test(v.title ?? ''))
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`));
}
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
];
export function checkBannedBody(text) {
return BANNED_BODY.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'BANNED_PHRASE', `금칙 표현: "${m[0]}"`));
}
// ---------- §3-11 원천 콘텐츠 ----------
export function checkOriginalContent(data, body) {
if (!['D', 'E'].includes(data.category)) return [];
const hasTable = /^\|.*\|\s*$/m.test(body);
const hasList = /^\s*(- \[ \]|\d+\.)\s/m.test(body);
return hasTable || hasList ? [] : [r(W, 'NO_TABLE_OR_CHECKLIST', `${data.category} 카테고리 글에 표·체크리스트가 없음 (원천 콘텐츠 5유형 중 하나여야 함)`)];
}
/** 홈페이지 문장 40자 연속 일치. homeText는 공백 제거본. */
export function checkHomeDuplicate(text, homeText, { len = 40, step = 20 } = {}) {
if (!homeText) return [];
const clean = text.replace(/\s+/g, '');
for (let i = 0; i + len <= clean.length; i += step) {
const chunk = clean.slice(i, i + len);
if (homeText.includes(chunk)) return [r(E, 'HOME_DUPLICATE', `홈페이지 문장 ${len}자 연속 일치: "${chunk}"`)];
}
return [];
}
// ---------- 묶음: 글 한 편의 소스 검사 ----------
export function checkPostSource({ data, body }, { physicians, supporters } = {}) {
const fields = postTextFields(data, body);
return [
...checkSourcesType(data),
...checkRegulationSource(data, fields),
...checkConclusions(fields),
...checkNumericContext(fields),
...checkNumberDates(fields),
...checkReview(data, physicians, supporters),
...checkVideoTitles([...(data.videos ?? []), ...(data.video ? [data.video] : [])], { label: 'frontmatter' }),
...checkOriginalContent(data, body),
...fields.flatMap((f) => checkPlainEndings(f.text, f.field)),
];
}

View File

@ -0,0 +1,67 @@
// 게이트 규칙 회귀 테스트. 빌드 없이 돈다.
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과
// 2) fixtures/*.html: 첫 줄 주석의 expect 코드 (none = 오류 0)
// 3) src/content/posts/*.md 전편이 소스 규칙 error 0 이어야 통과 (정답지 회귀)
// node scripts/gate/test.mjs
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import * as R from './rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const FIX = here('./fixtures/');
const POSTS = here('../../src/content/posts/');
const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8'));
const ctx = { physicians: authors.physicians, supporters: authors.supporters }; // 실제 글 회귀용
const fx = JSON.parse(readFileSync(join(FIX, '_authors.json'), 'utf8'));
const fxCtx = { physicians: fx.physicians, supporters: fx.supporters }; // 픽스처용
const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8'));
let pass = 0, failCount = 0;
const ok = (name, msg = '') => { pass++; console.log(` ✓ ${name}${msg ? ' · ' + msg : ''}`); };
const bad = (name, msg) => { failCount++; console.error(` ✗ ${name}: ${msg}`); };
const codes = (list, level = R.E) => [...new Set(list.filter((x) => x.level === level).map((x) => x.code))];
// 1) md 픽스처
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
const { expect = [], ...fm } = data;
const merged = { ...base, ...fm };
const got = codes(R.checkPostSource({ data: merged, body }, fxCtx));
const missing = expect.filter((c) => !got.includes(c));
const extra = got.filter((c) => !expect.includes(c));
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
else if (extra.length) bad(f, `기대 밖 오류: ${extra.join(', ')}`);
else ok(f, got.join(', '));
}
// 2) html 픽스처 (dist 규칙)
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.html'))) {
const html = readFileSync(join(FIX, f), 'utf8');
const expect = html.match(/expect:\s*([A-Z_]+|\(none\))/)?.[1];
const text = R.stripHtml(html);
const got = codes([...R.checkOperatorVocab(text, `/${f}`), ...R.checkBannedBody(text), ...R.checkHeadingEndings(html)]);
if (expect === '(none)') got.length ? bad(f, `오류 없어야 하는데: ${got.join(', ')}`) : ok(f, '오류 0');
else got.includes(expect) ? ok(f, got.join(', ')) : bad(f, `기대 ${expect}, 실제 ${got.join(', ') || '없음'}`);
}
// 3) 정답지 회귀: 실제 18편은 error 0
let postErrors = 0;
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E);
if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); }
}
if (!postErrors) ok('src/content/posts 전편', `소스 규칙 error 0 (${readdirSync(POSTS).filter((x) => x.endsWith('.md')).length}편)`);
// 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장)
const negatives = [
'다만 답변만으로 운영 실태나 수술 안전이 보장되는 것은 아닙니다.',
'나노텍스처를 선호한다는 것이 손유성 원장의 임상 판단이며, 독립 연구로 비교우위가 확정됐다는 뜻은 아닙니다.',
];
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);

View File

@ -0,0 +1,6 @@
---
import { fact, SITE_NAME, v } from '../lib';
---
<p class="disclosure">
{SITE_NAME} 활동은 {v(fact.shortName, '병원')}의 지원을 받습니다. 이 글에는 다른 환자의 치료 경험담과 전후 사진을 싣지 않습니다. {fact.sideEffectNotice}
</p>

View File

@ -0,0 +1,19 @@
---
import { fact, v, has } from '../lib';
const f = fact as Record<string, any>;
const rows = f.hours?.rows ?? [];
interface Props { compact?: boolean }
const { compact = false } = Astro.props;
---
<aside class="fact-block" aria-label="병원 기본 정보">
<h2>{v(f.shortName, '병원')} 기본 정보</h2>
<dl>
<dt>정식 명칭</dt><dd>{v(f.name)}</dd>
<dt>주소</dt><dd>{v(f.address?.full)}</dd>
<dt>전화</dt><dd>{has(f.phone) ? <a href={`tel:${f.phone.replace(/-/g, '')}`}>{f.phone}</a> : v(f.phone)}</dd>
<dt>진료시간</dt><dd>{rows.length ? rows.map((r: any) => r.open ? `${r.days} ${r.open}~${r.close}` : `${r.days} ${r.note}`).join(' · ') : v(null)}</dd>
<dt>가는 길</dt><dd>{v(f.transit)}</dd>
{!compact && <><dt>개원</dt><dd>{has(f.founded) ? `${f.founded}년` : v(null)}</dd><dt>규모</dt><dd>{v(f.building?.summary)}</dd></>}
</dl>
<p style="margin:0.8rem 0 0;font-size:0.8rem;color:var(--slate-500)">진료시간 출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 주소·전화는 {has(f.address?.source) ? <a href={f.address.source} rel="noopener">공식 홈페이지 표기</a> : '공식 홈페이지 표기'}를 따릅니다. 이 정보는 {v(f.checkedAt, '확인일 미정')}에 확인했고 다음 확인 예정일은 {v(f.nextCheck, '미정')}입니다.</p>
</aside>

View File

@ -0,0 +1,15 @@
---
interface Props { items: { q: string; a: string }[] }
const { items } = Astro.props;
---
{items.length > 0 && (
<section class="faq" aria-label="자주 묻는 질문">
<h2>자주 묻는 질문</h2>
{items.map((f) => (
<details>
<summary>{f.q}</summary>
<p>{f.a}</p>
</details>
))}
</section>
)}

View File

@ -0,0 +1,14 @@
---
interface Props { items: { src: string; alt: string; caption: string }[]; cols?: 2 | 3 }
const { items, cols = 2 } = Astro.props;
---
{items.length > 0 && (
<div class={`gallery cols-${cols}`}>
{items.map((g) => (
<figure class="figure">
<img src={g.src} alt={g.alt} loading="lazy" />
<figcaption>{g.caption}</figcaption>
</figure>
))}
</div>
)}

View File

@ -0,0 +1,16 @@
---
// 갱신 기록. 무엇을 언제 바꿨는지 독자가 볼 수 있게 한다.
interface Props { items: { date: string; note: string }[] }
const { items } = Astro.props;
---
{items.length > 0 && (
<section class="history" aria-label="갱신 기록">
<h2>갱신 기록</h2>
<ul>{[...items].reverse().map((h) => <li><time datetime={h.date}>{h.date}</time> {h.note}</li>)}</ul>
</section>
)}
<style>
.history ul { list-style: none; padding: 0; margin: 0; font-size: 0.92rem; }
.history li { padding: 0.35rem 0; border-bottom: 1px solid var(--slate-100); }
.history time { display: inline-block; min-width: 6.5em; color: var(--slate-500); font-variant-numeric: tabular-nums; }
</style>

View File

@ -0,0 +1,18 @@
---
import { CATEGORY_LABELS, supporter, site as S, fact } from '../lib';
interface Props { post: any }
const { post } = Astro.props;
const d = post.data;
const v0 = d.videos?.[0] ?? d.video;
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? '/favicon.svg');
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? `${fact.shortName} 건물 외관`);
---
<article class="card has-thumb">
<a class="thumb" href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>
<div class="body">
<div class="cat">{d.category} · {CATEGORY_LABELS[d.category]}</div>
<h3><a href={`/posts/${post.id}`}>{d.title}</a></h3>
<p>{d.summary[0]}</p>
<div class="foot">{supporter(d.author).name} · {d.dateModified} 갱신</div>
</div>
</article>

View File

@ -0,0 +1,11 @@
---
import VideoLite from './VideoLite.astro';
interface Props { items: { id: string; title: string; speaker?: string; published?: string; note?: string }[]; title?: string }
const { items, title = '관련 영상' } = Astro.props;
---
{items.length > 0 && (
<section>
<h2>{title}</h2>
<div class="video-grid">{items.map((v) => <VideoLite {...v} compact />)}</div>
</section>
)}

View File

@ -0,0 +1,19 @@
---
import { supporter, physician } from '../lib';
interface Props { author: string; reviewer?: string; status: 'reviewed' | 'pending'; dateModified: string }
const { author, reviewer, status, dateModified } = Astro.props;
const a = supporter(author);
const p = reviewer ? physician(reviewer) : null;
---
<section class="review-box" aria-label="작성·감수 정보">
{p?.image ? <img src={p.image} alt={`${p.name} ${p.title}`} width="56" height="56" /> : <div class="avatar">{a.name.slice(0, 1)}</div>}
<div>
<div><strong>작성</strong> <a href={`/authors/${author}`}>{a.name}</a> · {a.role} {a.email ? <> · 정정 요청 <a href={`mailto:${a.email}`}>{a.email}</a></> : <> · 정정 요청은 <a href="/corrections">정정 기록</a> 페이지 안내를 따릅니다</>}</div>
{p && status === 'reviewed' ? (
<div><strong>의학 검토</strong> {p.name} {p.title} <span class="badge reviewed">검토 완료 {dateModified}</span></div>
) : (
<div><strong>의학 검토</strong> <span class="badge pending">검토 대기</span> <span style="color:var(--slate-500)">병원이 담당 원장과 검토일을 확정하면 표시합니다.</span></div>
)}
<div style="color:var(--slate-500);margin-top:0.3rem">서포터즈는 의학적 판단을 하지 않습니다. 시술 여부와 방법은 반드시 전문의 상담으로 결정하세요.</div>
</div>
</section>

View File

@ -0,0 +1,16 @@
---
// 참고 자료. type으로 근거의 성격을 구분한다. 규제·연구 자료가 있는 주장은 영상 재진술이 아니라 원문까지 닿게 한다.
interface Props { items: { label: string; url: string; accessed: string; type?: string }[] }
const { items } = Astro.props;
const TYPE: Record<string, string> = { clinic: '병원 제공', doctor: '원장 설명 (영상)', product: '제품 정보 (제조사)', regulation: '규제·연구 자료', platform: '플랫폼 집계', secondary: '2차 자료' };
---
{items.length > 0 && (
<section class="sources">
<h2>참고한 자료</h2>
<ul>{items.map((s) => <li>{s.type && <span class="stype">{TYPE[s.type] ?? s.type}</span>} <a href={s.url} rel="noopener">{s.label}</a> <span style="color:var(--slate-500)">({s.accessed} 확인)</span></li>)}</ul>
<p style="font-size:0.83rem;color:var(--slate-500);margin:0.6rem 0 0">원장 설명은 영상 시점의 임상 의견이고, 제품 정보는 제조사 자료이며, 규제·연구 자료만 독립 검증에 해당합니다.</p>
</section>
)}
<style>
.stype { display: inline-block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.03em; color: var(--st-info-text); background: var(--st-info-bg); border: 1px solid var(--st-info-border); border-radius: 999px; padding: 0.05rem 0.55rem; margin-right: 0.35rem; vertical-align: 1px; }
</style>

View File

@ -0,0 +1,9 @@
---
// 직접 답변 블록. 기업은행 블로그의 "세 줄 요약"에서 가져온 구조. 답변엔진이 잘라 쓰는 단락이다.
interface Props { items: string[] }
const { items } = Astro.props;
---
<section class="summary" aria-label="세 줄 요약">
<h2>세 줄 요약</h2>
<ol>{items.map((t) => <li>{t}</li>)}</ol>
</section>

View File

@ -0,0 +1,9 @@
---
interface Props { id: string; title: string; channel: string; speaker?: string; published?: string }
const { id, title, channel, speaker, published } = Astro.props;
---
<h2>원본 영상</h2>
<div class="video">
<iframe loading="lazy" src={`https://www.youtube-nocookie.com/embed/${id}`} title={title} allow="accelerometer; encrypted-media; picture-in-picture" allowfullscreen></iframe>
</div>
<p class="video-note">{channel} · {title}{speaker ? ` · 출연 ${speaker}` : ''}{published ? ` · ${published} 게시` : ''}. 이 글은 영상 발화를 서포터즈가 글로 재구성한 것이며, 영상 설명란 텍스트를 복사하지 않았습니다.</p>

View File

@ -0,0 +1,27 @@
---
// 유튜브 라이트 임베드: 썸네일(i.ytimg.com) + 재생 버튼. 클릭 시 iframe으로 교체. 헤드리스·저속 환경에서도 시각적으로 보인다.
import { surface } from '../lib';
interface Props { id: string; title: string; speaker?: string; published?: string; note?: string; compact?: boolean }
const handle = surface('youtube').handle ? `유튜브 ${surface('youtube').handle}` : '유튜브';
const { id, title, speaker, published, note, compact = false } = Astro.props;
---
<figure class={`yt ${compact ? 'compact' : ''}`}>
<button class="yt-btn" type="button" data-yt={id} aria-label={`영상 재생: ${title}`}>
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} alt={`유튜브 영상 썸네일: ${title}`} loading="lazy" width="480" height="360" />
<span class="play" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></span>
</button>
<figcaption>
<strong>{title}</strong>
<span>{speaker ? `${speaker} · ` : ''}{published ? `${published} 게시 · ` : ''}{handle}{note ? ` · ${note}` : ''}</span>
</figcaption>
</figure>
<script>
document.querySelectorAll<HTMLButtonElement>('.yt-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.yt!;
const wrap = document.createElement('div'); wrap.className = 'yt-frame';
wrap.innerHTML = `<iframe src="https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0" title="YouTube video" allow="accelerometer; autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>`;
btn.replaceWith(wrap);
}, { once: true });
});
</script>

View File

@ -0,0 +1,32 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro:schema';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: z.object({
title: z.string(), // H1. 질문형 한 문장
category: z.enum(['A', 'B', 'C', 'D', 'E', 'F', 'G']),
categoryLabel: z.string(),
qbIds: z.array(z.string()), // QB 120 문항 ID
summary: z.array(z.string()).min(2).max(4), // 세 줄 요약 (직접 답변 블록)
description: z.string(), // meta description
author: z.string(), // authors.json id
reviewer: z.string().optional(), // 감수자 id (physicians)
reviewStatus: z.enum(['reviewed', 'pending']).default('pending'),
reviewedAt: z.string().optional(), // 의학 검토 완료일. reviewed일 때 필수로 쓴다
datePublished: z.string(),
dateModified: z.string(),
video: z.object({ id: z.string(), title: z.string(), channel: z.string(), speaker: z.string().optional(), published: z.string().optional() }).optional(),
videos: z.array(z.object({ id: z.string(), title: z.string(), speaker: z.string().optional(), published: z.string().optional(), note: z.string().optional() })).default([]),
gallery: z.array(z.object({ src: z.string(), alt: z.string(), caption: z.string() })).default([]),
thumbnail: z.string().optional(),
hero: z.object({ src: z.string(), alt: z.string(), caption: z.string() }).optional(),
faq: z.array(z.object({ q: z.string(), a: z.string() })).default([]),
sources: z.array(z.object({ label: z.string(), url: z.string(), accessed: z.string(), type: z.enum(['clinic', 'doctor', 'product', 'regulation', 'platform', 'secondary']).optional() })).default([]),
history: z.array(z.object({ date: z.string(), note: z.string() })).default([]), // 갱신 기록. 화면에 그대로 노출
tags: z.array(z.string()).default([]),
}),
});
export const collections = { posts };

View File

@ -0,0 +1,13 @@
{
"_comment": "작성자(supporters)와 의학 검토 원장(physicians). 작성자 실명·직함·sameAs·정정 이메일은 병원/운영자 입력값(v2 §6-1). 검토 원장은 병원이 확정한 사람만 넣는다(§3-7).",
"supporters": {
"editor": {
"name": "편집 책임자 (확인 대기)",
"role": "서포터즈 편집 책임",
"bio": "병원을 알아보는 분들이 실제로 묻는 질문을 고르고, 원장 설명 영상과 병원 공개 자료를 근거로 글을 쓰며 편집 책임을 집니다. 의학적 판단은 하지 않으며, 의학 내용은 병원 담당 원장의 검토를 거쳐 표시합니다.",
"email": "",
"sameAs": []
}
},
"physicians": {}
}

View File

@ -0,0 +1,6 @@
{
"_comment": "정정 기록. 접수·처리한 정정을 날짜순으로 남긴다. contact·owner는 병원/운영자 입력값.",
"contact": "",
"owner": "",
"items": []
}

View File

@ -0,0 +1,32 @@
{
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
"name": "",
"shortName": "",
"kind": "",
"areaLabel": "",
"founded": "",
"representative": "",
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" },
"phone": "",
"fax": "",
"kakao": "",
"url": "",
"urlEn": "",
"reservationUrl": "",
"doctorsUrl": "",
"transit": "",
"transitShort": "",
"transitNote": "",
"parking": "",
"hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] },
"building": { "summary": "", "floors": [], "source": "" },
"departments": [],
"specialties": [],
"safety": { "items": [], "source": "" },
"recognitions": [],
"surfaces": {},
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
"businessNo": "",
"checkedAt": "",
"nextCheck": ""
}

View File

@ -0,0 +1,6 @@
{
"_comment": "뉴스룸 데이터. scripts/build_news.py 결과. 병원 배포 기사·원장 기고만, 제3자 규제·사건 보도 제외.",
"generatedAt": null,
"sources": [],
"items": []
}

View File

@ -0,0 +1,24 @@
{
"_comment": "매체 고유값. 비어 있으면(null·[]) 해당 블록은 '확인 대기' 문장으로 렌더된다. 이미지는 병원 허락을 받은 것만 public/img/ 에 넣고 여기서 참조한다.",
"siteName": "",
"siteNameEn": "SUPPORTERS",
"eyebrow": "",
"tagline": "",
"heroTitle": "궁금한 것부터 답합니다.",
"editorId": "editor",
"logo": null,
"logoFoot": null,
"heroImage": null,
"buildingImage": null,
"imageCredit": "",
"homeVideos": [],
"insideSummary": "",
"insideGallery": [],
"clinicGallery": [],
"clinicDoctors": [],
"visitMaps": [],
"arrivalGuide": "",
"visitReads": [],
"newsNote": "",
"newsOutletsExample": ""
}

View File

@ -0,0 +1,10 @@
{
"_comment": "유튜브 채널 집계. uploads 전량 → videos.list → 조회수 순위. 쇼츠 판별은 /shorts/{id} 200 응답. topLong·shortsInfo는 정보형만(제목 금칙 필터 통과).",
"fetchedAt": null,
"channel": {},
"top": [],
"topLong": [],
"topShorts": [],
"shortsInfo": [],
"shortsDetection": ""
}

View File

@ -0,0 +1,95 @@
---
import '../styles/global.css';
import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib';
const ed = editor();
const f = fact as Record<string, any>;
interface Props {
title: string;
description: string;
jsonLd?: Record<string, any> | Record<string, any>[];
ogImage?: string;
type?: 'website' | 'article';
noindex?: boolean;
}
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props;
const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null;
---
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | {SITE_NAME}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{(!INDEXABLE || noindex) && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL(ogImage, Astro.site).toString()} />
<meta property="og:locale" content="ko_KR" />
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
{ld && <script type="application/ld+json" set:html={ld} />}
</head>
<body>
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>}
<header class="site-header">
<div class="wrap-wide bar">
<a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
<div class="right">
<nav class="nav">
<a href="/posts">상담 전 질문</a>
<a href="/videos">의료진 영상</a>
<a href="/newsroom">뉴스룸</a>
<a href="/visit">방문 안내</a>
<a href="/about">매체 소개</a>
</nav>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a>
</div>
</div>
</header>
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && (
<div class="backbar-wrap"><div class="wrap-wide backbar">
<a href="/" class="backbtn" id="backbtn" aria-label="이전 페이지로 돌아가기"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>이전 페이지</a>
</div></div>
)}
<main>
<slot />
</main>
<button type="button" class="totop" id="totop" aria-label="맨 위로"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button>
<script>
const back = document.getElementById('backbtn');
if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } });
const top = document.getElementById('totop')!;
const onScroll = () => top.classList.toggle('show', window.scrollY > 400);
window.addEventListener('scroll', onScroll, { passive: true }); onScroll();
top.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
</script>
<footer class="site-footer">
<div class="wrap-wide cols">
<div>
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p>
<p>{fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p>
</div>
<div>
{has(S.logoFoot?.src) && <p><img src={S.logoFoot.src} alt={S.logoFoot.alt} width={S.logoFoot.width} height={S.logoFoot.height} style="width:138px;height:auto;margin-bottom:0.5rem" /></p>}
<p><strong>{v(f.name)}</strong></p>
<p>{v(f.address?.full)}<br />전화 {v(f.phone)} · 팩스 {v(f.fax)}<br />대표 {v(f.representative)} · 사업자등록번호 {v(f.businessNo)}</p>
<p>{has(f.url) && <a href={f.url} rel="noopener">공식 홈페이지</a>}{has(surface('youtube').url) && <> · <a href={surface('youtube').url} rel="noopener">유튜브</a></>}{has(surface('gangnamunni').url) && <> · <a href={surface('gangnamunni').url} rel="noopener">강남언니</a></>}</p>
</div>
</div>
<div class="wrap-wide powered">AI Discovery · Built with INFINITH</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,82 @@
// 데이터 진입점. 병원 사실(factSheet) · 사람(authors) · 매체(site) 세 파일만 읽고, 페이지는 이 모듈을 통해서만 값을 쓴다.
// 값이 비어 있으면(null·''·[]) 화면에는 PENDING("확인 대기")을 쓰고 스키마에서는 그 필드를 뺀다. 지어내지 않는다.
import fact from './data/factSheet.json';
import authors from './data/authors.json';
import site from './data/site.json';
export const PENDING = '확인 대기';
export const SITE_NAME = site.siteName || `${fact.shortName || '병원'} 서포터즈`;
export const SITE_TAGLINE = site.tagline || `${fact.shortName || '병원'}를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체`;
export const INDEXABLE = import.meta.env.PUBLIC_INDEXABLE === 'true';
export const CATEGORY_LABELS: Record<string, string> = {
A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·신뢰', F: '가격·이벤트', G: '후기·평판',
};
export { fact, authors, site };
/** 비어 있으면 PENDING. 화면 표시용. */
export const v = (x: unknown, fallback: string = PENDING): string => (x === null || x === undefined || x === '' ? fallback : String(x));
/** 비어 있지 않은 값만. 스키마·링크용. */
export const has = (x: unknown): boolean => !(x === null || x === undefined || x === '' || (Array.isArray(x) && x.length === 0));
type AnyRec = Record<string, any>;
export const surfaces: AnyRec = (fact as AnyRec).surfaces ?? {};
export const surface = (key: string): AnyRec => surfaces[key] ?? {};
export const reservationUrl: string = (fact as AnyRec).reservationUrl || fact.url || '';
export const editor = (): AnyRec => supporter(site.editorId) ?? { name: PENDING, role: '편집 책임', email: '', bio: '' };
export function supporter(id: string) {
return (authors.supporters as Record<string, any>)[id];
}
export function physician(id: string) {
return (authors.physicians as Record<string, any>)[id];
}
const compact = (o: AnyRec): AnyRec => Object.fromEntries(Object.entries(o).filter(([, val]) => has(val)));
export function clinicSchema(siteUrl: string) {
const f = fact as AnyRec;
const sameAs = ['youtube', 'instagram', 'facebook', 'gangnamunni'].map((k) => surface(k).url).concat(f.urlEn).filter(has);
return compact({
'@type': ['MedicalClinic', 'Organization'],
'@id': `${f.url}/#clinic`,
name: f.name,
alternateName: f.shortName,
url: f.url,
telephone: f.phone,
faxNumber: f.fax,
foundingDate: f.founded,
address: has(f.address?.street) ? {
'@type': 'PostalAddress',
streetAddress: f.address.street,
addressLocality: f.address.locality,
addressRegion: f.address.region,
addressCountry: 'KR',
} : null,
openingHoursSpecification: (f.hours?.schema ?? []).map((h: AnyRec) => ({ '@type': 'OpeningHoursSpecification', ...h })),
medicalSpecialty: ['PlasticSurgery'],
sameAs,
image: has(site.buildingImage?.src) ? `${siteUrl}${site.buildingImage.src}` : null,
});
}
export function personSchema(id: string, siteUrl: string) {
const a = supporter(id);
return compact({ '@type': 'Person', '@id': `${siteUrl}/authors/${id}#person`, name: a.name, jobTitle: a.role, url: `${siteUrl}/authors/${id}`, sameAs: a.sameAs });
}
export function physicianSchema(id: string) {
const p = physician(id);
return compact({
'@type': 'Physician',
'@id': `${p.url}#physician`,
name: p.name,
jobTitle: p.title,
url: p.url,
sameAs: p.sameAs,
medicalSpecialty: 'PlasticSurgery',
worksFor: { '@id': `${fact.url}/#clinic` },
image: p.image ?? null,
});
}

View File

@ -0,0 +1,45 @@
---
import Base from '../layouts/Base.astro';
import { fact, SITE_NAME, editor, reservationUrl, v, has } from '../lib';
const ed = editor();
const clinic = v(fact.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'AboutPage', '@id': `${site}/about#page`, name: '이 사이트에 대해', url: `${site}/about`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
---
<Base title="이 사이트에 대해" description={`${SITE_NAME}는 ${clinic}를 알아보는 분들의 질문에 원장 영상과 공개 자료를 근거로 답하는 곳입니다. 누가 쓰고 누가 감수하는지, 무엇을 싣지 않는지, 병원과의 관계를 밝힙니다.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">About</div><h1 class="serif">About This Site</h1><p style="color:var(--slate-600);margin-top:0.6rem">누가 쓰는지, 무엇을 근거로 하는지, 무엇을 싣지 않는지.</p></header>
<h2>어떤 곳인가요</h2>
<p>{SITE_NAME}는 {clinic}를 알아보는 분들이 실제로 묻는 질문에 답하는 곳입니다. 병원 홈페이지가 병원을 소개한다면, 이곳은 "가기 전에 뭘 알아야 하지?", "이 수술은 어떻게 진행되지?", "후기는 어디서 보지?" 같은 환자 쪽 질문에서 출발합니다.</p>
<h2>답은 무엇을 근거로 하나요</h2>
<ul>
<li>{clinic} 원장들이 직접 설명한 유튜브 영상. 글에서 원장이 한 말은 영상을 함께 붙여 직접 확인할 수 있게 합니다.</li>
<li>병원이 공개한 자료. 홈페이지, 강남언니 병원 페이지, 언론 보도.</li>
<li>주소, 전화, 진료시간 같은 기본 정보는 <a href="/clinic">병원 정보</a> 페이지와 같은 내용을 씁니다.</li>
</ul>
<p>글마다 맨 아래 "참고한 자료"에 링크와 확인한 날짜를 적습니다.</p>
<h2>누가 쓰고 누가 확인하나요</h2>
<p>글은 {SITE_NAME} 편집자 {ed.name}이 쓰고 편집 책임을 집니다. 의학적인 내용은 {clinic} 담당 원장이 검토하며, 검토가 끝난 글에는 "검토 완료"와 날짜를, 아직 검토 전인 글에는 "검토 대기"를 표시합니다. 편집자는 의학적 판단을 하지 않습니다. 수술 여부와 방법은 반드시 전문의 상담으로 정하세요.</p>
<h2>싣지 않는 것</h2>
<ul>
<li>전후 사진</li>
<li>다른 환자의 후기와 경험담. 후기는 강남언니, 구글, 네이버 플레이스의 집계 수치와 링크로만 안내합니다.</li>
<li>다른 병원과의 비교, 효과를 보장하는 표현, "최고" 같은 최상급 표현</li>
<li>가격. 이벤트 가격은 병원과 강남언니 페이지로 안내만 합니다.</li>
</ul>
<p>이유는 간단합니다. 병원의 지원을 받는 사이트가 경험담이나 전후 사진으로 설득하면 광고가 되고, 읽는 분이 판단할 근거가 아니라 인상만 남기 때문입니다.</p>
<h2>병원과의 관계</h2>
<p>{SITE_NAME} 활동은 {clinic}의 지원을 받습니다. 이 사실은 모든 글 아래에 같은 문장으로 표시합니다. 감춘 광고가 아니라는 뜻이며, 그래서 위의 "싣지 않는 것"을 지킵니다.</p>
<h2>정정 요청과 문의</h2>
<p>글의 오류나 정정 요청은 편집 책임자 {ed.name}에게 {ed.email ? <>이메일 <a href={`mailto:${ed.email}`}>{ed.email}</a>로</> : <>이메일로(주소 {v(null)})</>} 보내 주세요. 접수한 요청과 처리 결과는 <a href="/corrections">정정 기록</a>에 날짜와 함께 남깁니다. 진료·예약 문의는 {clinic} 공식 채널로 하세요. {has(fact.url) && <a href={fact.url} rel="noopener">홈페이지</a>}{has(fact.kakao) && <> · <a href={fact.kakao} rel="noopener">카카오톡 상담 채널</a></>} · 전화 {v(fact.phone)}</p>
<p style="font-size:0.88rem;color:var(--slate-500);margin-top:2.5rem">이 사이트를 만드는 방식(글 구조, 중복 콘텐츠 규칙, 발행 전 검사)이 궁금한 운영자는 <a href="/editorial">편집 기준</a>을 보세요.</p>
<p class="disclosure">{fact.sideEffectNotice}</p>
</article>
</Base>

View File

@ -0,0 +1,19 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
import { authors, personSchema } from '../../lib';
export async function getStaticPaths() {
return Object.keys(authors.supporters).map((id) => ({ params: { id } }));
}
const { id } = Astro.params;
const a = (authors.supporters as Record<string, any>)[id!];
const posts = (await getCollection('posts')).filter((p) => p.data.author === id);
const site = Astro.site!.toString().replace(/\/$/, '');
---
<Base title={`${a.name} · 작성 글`} description={a.bio} jsonLd={personSchema(id!, site)}>
<div class="wrap-wide" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">{a.role}</div><h1>{a.name}</h1><p style="color:var(--slate-600);margin-top:0.6rem;max-width:640px">{a.bio}</p></header>
<div class="grid">{posts.map((p) => <PostCard post={p} />)}</div>
</div>
</Base>

View File

@ -0,0 +1,45 @@
---
import Base from '../layouts/Base.astro';
import FactBlock from '../components/FactBlock.astro';
import { fact, site as S, clinicSchema, physicianSchema, physician, surface, v, has } from '../lib';
import Gallery from '../components/Gallery.astro';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const docIds = (S.clinicDoctors ?? []).filter((id) => physician(id));
const docs = docIds.map(physician);
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [clinicSchema(site), ...docIds.slice(0, 1).map(physicianSchema)];
const yt = surface('youtube'); const gu = surface('gangnamunni'); const blog = surface('blog');
---
<Base title={`${clinic} 기본 정보`} description={`${v(f.name, clinic)}의 주소·전화·진료시간·층별 구성·진료과·공개 이력을 한 곳에 정리했습니다. 방문 전에 확인하세요.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Fact Sheet</div><h1 class="serif">Fact Sheet</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">{clinic} 기본 정보.</strong> 주소, 전화, 진료시간, 층별 안내, 의료진을 한 곳에 모았습니다. 항목마다 어디서 확인한 정보인지 출처를 적어 두었습니다.</p></header>
<FactBlock />
<h2>진료시간</h2>
<div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{(f.hours?.rows ?? []).map((r: any) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}{!(f.hours?.rows ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p>
<h2>건물과 층별 구성</h2>
{has(S.buildingImage?.src) && <figure class="figure"><img src={S.buildingImage.src} alt={S.buildingImage.alt} /><figcaption>{S.buildingImage.caption}</figcaption></figure>}
<p>{has(f.building?.summary) ? `${f.building.summary}입니다.` : `건물 규모 ${v(null)}.`}</p>
<div class="table-wrap"><table><thead><tr><th>층</th><th>용도</th></tr></thead><tbody>{(f.building?.floors ?? []).map((fl: any) => <tr><td>{fl.floor}</td><td>{fl.use}</td></tr>)}{!(f.building?.floors ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
{S.clinicGallery?.length > 0 && <Gallery cols={3} items={S.clinicGallery} />}
<h2>진료과와 진료 분야</h2>
<p>진료과: {(f.departments ?? []).join(', ') || v(null)}</p>
<p>진료 분야: {(f.specialties ?? []).join(', ') || v(null)}</p>
<h2>글과 영상에서 설명하는 의료진</h2>
{!docs.length && <p style="color:var(--slate-500)">의료진 정보는 병원이 확정한 담당 원장부터 싣습니다. {v(null)}.</p>}
{docs.map((p) => (<div class="doctor-strip">{p.image && <img src={p.image} alt={`${p.name} ${p.title}`} />}<div><div class="name">{p.name} <span style="font-weight:500;color:var(--slate-600)">{p.title}</span></div><p class="creds">{p.credentials.slice(0,3).join(' · ')} <a href={p.url} rel="noopener">프로필</a></p></div></div>))}
<p style="font-size:0.88rem;color:var(--slate-500)">전체 의료진 {v(gu.doctors)}명(강남언니 등록 기준)은 {has(f.doctorsUrl) ? <a href={f.doctorsUrl} rel="noopener">홈페이지 의료진 소개</a> : '홈페이지 의료진 소개'}에서 확인할 수 있습니다.</p>
<h2>병원이 공개한 이력</h2>
<ul>{(f.recognitions ?? []).map((r: any) => <li>{r.text} <a href={r.source} rel="noopener" style="font-size:0.85rem">(출처)</a></li>)}{!(f.recognitions ?? []).length && <li>{v(null)}</li>}</ul>
<h2>공식 채널</h2>
<ul>
<li>홈페이지: {has(f.url) ? <a href={f.url} rel="noopener">{f.url}</a> : v(null)}{has(f.urlEn) && <> (영문 <a href={f.urlEn} rel="noopener">{f.urlEn}</a>)</>}</li>
<li>유튜브: {has(yt.url) ? <a href={yt.url} rel="noopener">{yt.handle}</a> : v(null)} · 구독자 {v(yt.subscribers)} · 영상 {v(yt.videos)}개 ({v(yt.checked)} 기준)</li>
<li>강남언니: {has(gu.url) ? <a href={gu.url} rel="noopener">병원 페이지</a> : v(null)} · 평점 {v(gu.rating)} · 후기 {v(gu.reviews)}건 · 의료진 {v(gu.doctors)}명</li>
<li>카카오톡 채널: {has(f.kakao) ? <a href={f.kakao} rel="noopener">상담 채널</a> : v(null)}</li>
<li>블로그: {has(blog.url) ? <a href={blog.url} rel="noopener">{blog.url.replace(/^https?:\/\//, '').replace(/\/$/, '')}</a> : v(null)}{has(blog.note) && <> ({blog.note})</>}</li>
</ul>
<p class="disclosure">{fact.sideEffectNotice}</p>
</article>
</Base>

View File

@ -0,0 +1,24 @@
---
// 정정 기록. 무엇을 언제 왜 고쳤는지 공개한다. 정정 요청 경로와 처리 담당자를 명시한다.
import Base from '../layouts/Base.astro';
import data from '../data/corrections.json';
import { SITE_NAME, v } from '../lib';
const site = Astro.site!.toString().replace(/\/$/, '');
const items = [...data.items].sort((a, b) => b.date.localeCompare(a.date));
const ld = { '@type': 'WebPage', '@id': `${site}/corrections#page`, name: '정정 기록', url: `${site}/corrections`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: items[0]?.date };
---
<Base title="정정 기록 · 정정 요청 방법" description={`${SITE_NAME} 글의 정정 기록과 정정 요청 방법. 무엇을 언제 왜 고쳤는지 날짜와 함께 공개합니다.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Corrections</div><h1 class="serif">Corrections</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">정정 기록.</strong> 글에서 고친 내용을 날짜와 이유와 함께 남깁니다.</p></header>
<h2>정정 요청 방법</h2>
<p>글의 오류, 오래된 수치, 근거가 다른 내용을 발견하면 편집 책임자 {v(data.owner)}에게 {data.contact ? <>이메일 <a href={`mailto:${data.contact}`}>{data.contact}</a>로</> : <>이메일로(주소 {v(null)})</>} 보내 주세요. 어느 글의 어느 문장인지와 근거 자료를 함께 주시면 빠릅니다. 접수한 요청은 확인 뒤 이 페이지에 처리 결과를 남깁니다. 진료·예약 문의는 병원 공식 채널로 하세요.</p>
<h2>정정 기록</h2>
<div class="table-wrap"><table><thead><tr><th>날짜</th><th>페이지</th><th>무엇이 문제였나</th><th>어떻게 고쳤나</th><th>계기</th></tr></thead><tbody>
{!items.length && <tr><td colspan="5" style="color:var(--slate-500)">아직 정정한 내용이 없습니다.</td></tr>}
{items.map((c) => <tr><td style="white-space:nowrap">{c.date}</td><td><a href={c.page}>{c.page}</a></td><td>{c.what}</td><td>{c.fix}</td><td>{c.source}</td></tr>)}
</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">글마다 하단의 "갱신 기록"에도 같은 변경이 날짜와 함께 표시됩니다.</p>
</article>
</Base>

View File

@ -0,0 +1,84 @@
---
// 운영자용 페이지. 병원 담당자·서포터즈·INFINITH가 보는 편집 기준이다. 고객 내비게이션에는 넣지 않고 푸터에서만 연결하며, 색인하지 않는다.
import Base from '../layouts/Base.astro';
import { fact, SITE_NAME } from '../lib';
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'WebPage', '@id': `${site}/editorial#page`, name: '편집 기준', url: `${site}/editorial`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
---
<Base title="편집 기준 (운영자용)" description={`${SITE_NAME} 운영자용 편집 기준. 질문 뱅크, 글 구조, 원천 변환 규칙, 중복 콘텐츠 회피, 구조화 데이터, 발행 전 검사.`} jsonLd={ld} noindex>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Editorial Policy</div><h1 class="serif">Editorial Policy</h1><p style="color:var(--slate-600);margin-top:0.6rem">이 페이지는 병원 담당자, 서포터즈, INFINITH 운영진이 보는 문서입니다. 방문자에게 보이는 소개는 <a href="/about">이 사이트에 대해</a>에 따로 있습니다.</p></header>
<div class="notice">고객 화면에는 "AI가 인용하는 방식"이나 제작 원칙을 쓰지 않습니다. 방문자는 답을 보러 오지 제작 과정을 보러 오지 않습니다. 이 페이지의 내용은 이 페이지 밖으로 내보내지 않습니다.</div>
<h2>1. 왜 이 사이트인가</h2>
<p>답변엔진(ChatGPT, Perplexity, 네이버 AI 브리핑)은 광고 문장이 아니라 질문에 바로 답하는 단락을 잘라 인용합니다. 병원 홈페이지는 소개 중심이라 그 단락이 없습니다. 이 사이트는 환자 질문에 답하는 단락을 먼저 만들고, 그 답을 원장 영상과 공개 자료로 뒷받침하는 것이 목적입니다.</p>
<h2>2. 질문 뱅크</h2>
<p>질문은 INFINITH가 실측한 환자 질문 뱅크 120문항(카테고리 A~G)에서 고릅니다. 문항 하나가 글 하나이고, 각 글의 문항 번호는 페이지 소스의 <code>data-qb</code> 속성에만 남기고 화면에는 표시하지 않습니다.</p>
<div class="table-wrap"><table><thead><tr><th>카테고리</th><th>내용</th><th>글 포맷</th></tr></thead><tbody>
<tr><td>A 병원 소개</td><td>어떤 병원인지, 의료진</td><td>정의문 + 사실 표</td></tr>
<tr><td>B 방문·예약</td><td>가는 길, 예약, 준비</td><td>체크리스트</td></tr>
<tr><td>C 선택 기준</td><td>병원·원장 고르는 기준</td><td>기준 표</td></tr>
<tr><td>D 시술 정보</td><td>수술 방법, 보형물, 회복</td><td>원장 영상 재구성 + FAQ</td></tr>
<tr><td>E 안전·신뢰</td><td>마취, 실명제, 응급</td><td>사실 나열 + 출처</td></tr>
<tr><td>F 가격·이벤트</td><td>가격 확인처</td><td>안내만, 금액 미기재</td></tr>
<tr><td>G 후기·평판</td><td>후기 읽는 곳</td><td>집계 수치 + 링크</td></tr>
</tbody></table></div>
<h2>3. 글 구조</h2>
<ol>
<li>H1은 질문형 한 문장.</li>
<li>첫 블록 "세 줄 요약"에 답을 먼저 쓴다. 답변엔진이 잘라 쓰는 단락이다.</li>
<li>본문 H2 3~5개. 표는 HTML table. 수치는 단위 포함.</li>
<li>원장 영상은 글로 재구성하고 원본을 임베드한다. 영상 설명란 텍스트는 복사하지 않는다.</li>
<li>FAQ 5~8개. 질문 뱅크 문항을 그대로 질문으로 쓴다.</li>
<li>작성·감수 정보, 참고 자료, 병원 정보 블록, 지원 관계 고지 순으로 닫는다.</li>
</ol>
<h2>4. 원천 변환 규칙</h2>
<ul>
<li>홈페이지 문장은 옮겨 적지 않는다. 사실(개원 연도, 층 구성, 진료시간)만 가져오고 문장은 새로 쓴다.</li>
<li>주소·전화·진료시간·의료진 수는 <code>src/data/factSheet.json</code> 한 곳에서만 가져온다. 화면의 <a href="/clinic">병원 정보</a>도 같은 파일에서 나온다.</li>
<li>이미지는 병원 공개 자료를 캡처해 쓰되, alt와 캡션은 글 문맥에 맞게 새로 쓰고 출처를 적는다.</li>
<li>후기 본문은 인용하지 않는다. 의료법 제56조가 광고에서 치료 경험담을 금지하고, 병원 지원을 받는 이 사이트는 광고로 볼 여지가 있다. 집계 수치와 링크만 쓴다.</li>
</ul>
<h2>5. 중복 콘텐츠를 피하는 규칙</h2>
<div class="table-wrap"><table><thead><tr><th>규칙</th><th>이유</th></tr></thead><tbody>
<tr><td>홈페이지 문장 40자 연속 일치 검사 후 발행</td><td>구글이 복제 사이트로 분류하거나 홈페이지와 순위를 나눠 갖는 일을 막는다.</td></tr>
<tr><td>허브(이 사이트)에 먼저 발행, 네이버 블로그는 24시간 뒤 요약 재작성</td><td>네이버 유사문서 판독은 뒤에 올라온 비슷한 글을 뺀다. 발행 순서가 원본 판정이다.</td></tr>
<tr><td>모든 페이지에 자기 참조 canonical</td><td>같은 글이 다른 주소로 복제돼도 원본이 한 곳으로 모인다.</td></tr>
<tr><td>홈페이지에 없는 정보 유형을 중심으로</td><td>준비 체크리스트, 회복 일정, 비교표, FAQ는 홈페이지가 다루지 않는 영역이다.</td></tr>
</tbody></table></div>
<h2>6. 구조화 데이터</h2>
<div class="table-wrap"><table><thead><tr><th>타입</th><th>용도</th></tr></thead><tbody>
<tr><td>Article + MedicalWebPage</td><td>글 본체. dateModified 필수</td></tr>
<tr><td>FAQPage</td><td>FAQ 블록</td></tr>
<tr><td>Person (author)</td><td>서포터즈. 확정 후 sameAs로 본인 SNS 연결</td></tr>
<tr><td>Physician (reviewedBy)</td><td>감수 원장. 감수 완료 상태에서만 넣는다. 감수 예정에 넣으면 허위 표기다</td></tr>
<tr><td>MedicalClinic (about)</td><td>{fact.shortName}. 홈페이지·유튜브·강남언니 sameAs</td></tr>
<tr><td>VideoObject</td><td>임베드 영상</td></tr>
</tbody></table></div>
<h2>7. 발행 전 검사</h2>
<p>정적 HTML로 빌드해 크롤러가 자바스크립트 없이 전문을 읽습니다. 빌드 뒤 <code>scripts/check.mjs</code>가 다음을 검사하고 하나라도 실패하면 배포하지 않습니다.</p>
<ol>
<li>JSON-LD 문법과 글 페이지의 Article/FAQPage 존재</li>
<li>모든 이미지의 alt</li>
<li>금칙 표현. 치료 결과를 단정하는 말, 부작용을 부정하는 문장, 최상급 병원 표현, 1인칭 경험담 어투. 목록은 <code>scripts/check.mjs</code>의 BANNED 배열에 있다</li>
<li>페이지당 H1 1개</li>
<li>홈페이지 문장 40자 연속 일치</li>
</ol>
<p>샘플 단계에서는 사이트 전체를 noindex로 두고, 이 페이지는 정식 오픈 뒤에도 noindex를 유지합니다.</p>
<h2>8. 고객 화면과 운영자 설명의 경계</h2>
<p>다음 표현은 고객 화면(홈, 글, 영상, 뉴스룸, 병원 정보, 소개)에 쓰지 않습니다.</p>
<ul>
<li>답변엔진, AI 인용, AEO/GEO, 질문 뱅크, QB 번호, INFINITH 실측</li>
<li>"홈페이지 문장을 복사하지 않습니다", "단일 원본", "수집 기준", "API 집계" 같은 제작 과정 설명</li>
<li>법 조문 번호. 고객에게는 "다른 환자의 경험담을 싣지 않습니다"까지만 말하고 이유는 한 문장으로 줄인다</li>
</ul>
<p>고객 화면에 남기는 것은 환자 보호 문구뿐입니다. 감수 상태, 참고 자료, 병원 지원 관계, 부작용 고지.</p>
</article>
</Base>

View File

@ -0,0 +1,108 @@
---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';
import PostCard from '../components/PostCard.astro';
import FactBlock from '../components/FactBlock.astro';
import VideoLite from '../components/VideoLite.astro';
import Gallery from '../components/Gallery.astro';
import { SITE_NAME, fact, site as S, clinicSchema, surface, v, has, PENDING } from '../lib';
const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified));
const site = Astro.site!.toString().replace(/\/$/, '');
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const area = has(f.areaLabel) ? `${f.areaLabel} 인근 ` : '';
const yt = surface('youtube'); const gu = surface('gangnamunni');
const ld = [
{ '@type': 'WebSite', '@id': `${site}/#website`, name: SITE_NAME, url: site, inLanguage: 'ko', about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base title={`${clinic}, ${(S.heroTitle || '궁금한 것부터 답합니다').replace(/\.$/, '')}`} description={`${clinic}${has(f.areaLabel) ? `(${f.areaLabel})` : ''}를 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원 공개 자료를 근거로 답합니다. 글마다 작성자와 원장 감수 여부를 밝힙니다.`} jsonLd={ld}>
<section class="hero-light">
<div class="blob a"></div><div class="blob b"></div>
<div class="wrap-wide inner">
<div>
<div class="eyebrow">{S.eyebrow || SITE_NAME}</div>
<h1>{clinic},<br /><span class="accent">{S.heroTitle || '궁금한 것부터 답합니다.'}</span></h1>
<p class="lede">{clinic}는 {has(f.founded) ? `${f.founded}년 개원한 ` : ''}{area}{v(f.kind, '병원')}입니다. 이곳은 병원을 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원이 공개한 자료를 근거로 답하는 곳입니다. 글마다 누가 썼고 원장 감수를 거쳤는지 밝힙니다.</p>
<div class="chips">
<span class="chip"><span class="dot"></span>{has(f.founded) ? `${f.founded}년 개원` : `개원 연도 ${PENDING}`} · {v(f.transitShort)}</span>
<span class="chip"><span class="dot"></span>원장이 직접 설명한 영상을 근거로</span>
</div>
<div class="hero .actions" style="display:flex;gap:0.8rem;flex-wrap:wrap;margin-top:1.6rem">
<a class="btn primary" href="/posts">전체 글 보기</a>
<a class="btn secondary" href="/clinic">병원 정보</a>
</div>
</div>
{has(S.heroImage?.src) && <div class="hero-img collage"><img src={S.heroImage.src} alt={S.heroImage.alt} width={S.heroImage.width} height={S.heroImage.height} /></div>}
</div>
</section>
<section class="section tint" style="padding:3.2rem 0">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Start Here</div><h2>Where Are You Now?</h2><p class="sub">지금 상황에 맞는 곳에서 시작하세요.</p></div>
<div class="grid">
<a class="card entry" href="/posts#cat-D"><div class="cat">처음 알아볼 때</div><h3>어떤 수술인지 알고 싶어요</h3><p>방법의 차이와 상담에서 확인할 기준을 살펴보세요.</p><span class="more">시술 정보 글 보기</span></a>
<a class="card entry" href="/posts#cat-E"><div class="cat">병원을 선택할 때</div><h3>안전과 회복이 걱정돼요</h3><p>병원이 공개한 관리 체계와 담당 의료진에게 물어볼 질문을 확인하세요.</p><span class="more">안전·신뢰 글 보기</span></a>
<a class="card entry" href="/visit"><div class="cat">방문을 준비할 때</div><h3>예약과 방문 준비가 궁금해요</h3><p>예약 경로, 가는 길, 주차, 도착 후 동선을 순서대로 안내합니다.</p><span class="more">방문 안내 보기</span></a>
</div>
</div>
</section>
<section class="section dark">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Why This Site</div><h2>Questions First</h2><p class="sub">병원에 가기 전에 알고 싶은 것부터 답합니다. 답은 원장이 직접 설명한 영상과 병원 공개 자료를 근거로 합니다.</p></div>
<div class="stats">
<div class="stat"><b>{posts.length}</b><span class="label">답한 질문</span><span class="desc">질문 하나에 글 하나. 첫 단락 "세 줄 요약"에 답을 먼저 쓰고, 그다음에 근거를 풉니다.</span></div>
<div class="stat"><b>{v(yt.videos)}</b><span class="label">원장 설명 영상</span><span class="desc">{v(yt.checked)} 확인. 글에서 원장이 한 말은 영상을 함께 붙여 두어 직접 확인할 수 있습니다.</span></div>
<div class="stat"><b>{v(gu.reviews)}</b><span class="label">강남언니 후기</span><span class="desc">{v(gu.checked)} 확인. 후기는 어디서 몇 건을 볼 수 있는지만 안내하고, 다른 환자의 경험담은 옮기지 않습니다.</span></div>
</div>
</div>
</section>
<section class="section light">
<div class="wrap-wide">
<div class="sec-head"><div class="row"><div><div class="eyebrow">Latest</div><h2>Recent Answers</h2><p class="sub">최근에 답한 질문. 첫 단락 "세 줄 요약"만 읽어도 답을 알 수 있습니다.</p></div><a href="/posts">전체 보기</a></div></div>
{posts.length ? <div class="grid">{posts.slice(0, 6).map((p) => <PostCard post={p} />)}</div> : <p class="sub" style="color:var(--slate-500)">아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.</p>}
</div>
</section>
<section class="section tint">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">From The Doctors</div><h2>Doctors Explain</h2><p class="sub">원장이 직접 설명하는 영상. 이 사이트의 글은 이런 영상을 근거로 씁니다.</p></div>
{S.homeVideos?.length ? (
<div class="video-grid" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))">
{S.homeVideos.map((vd) => <VideoLite id={vd.id} title={vd.title} published={vd.published} compact />)}
</div>
) : <p class="sub" style="color:var(--slate-500)">원장 설명 영상은 병원 공식 채널에서 골라 실을 예정입니다. {PENDING}.</p>}
</div>
</section>
<section class="section light">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow">Inside</div><h2>Inside The Building</h2><p class="sub">{v(S.insideSummary, `시설 안내 ${PENDING}`)}</p></div>
{S.insideGallery?.length ? (
<>
<Gallery cols={3} items={S.insideGallery} />
<p style="font-size:0.83rem;color:var(--slate-500);margin-top:-0.6rem">{v(S.imageCredit, '사진 출처: 병원 공식 홈페이지')}</p>
</>
) : <p class="sub" style="color:var(--slate-500)">시설 사진은 병원 공식 홈페이지에서 허락받은 사진만 싣습니다. {PENDING}.</p>}
</div>
</section>
<section class="section dark">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Principles</div><h2>What We Do, What We Don't</h2><p class="sub">읽는 분이 판단할 수 있도록 세 가지를 지킵니다.</p></div>
<div class="grid">
<div class="card"><h3>답하는 방식</h3><p>질문 하나에 글 하나. 첫 단락에 답을 먼저 씁니다. 의학적인 내용은 담당 원장이 감수하고, 감수가 끝난 글에는 날짜를, 아직인 글에는 감수 예정을 표시합니다.</p></div>
<div class="card"><h3>싣지 않는 것</h3><p>전후 사진, 다른 환자의 경험담, 다른 병원과의 비교, 효과를 보장하는 표현. 후기는 강남언니·구글에서 몇 건을 볼 수 있는지와 링크만 안내합니다.</p></div>
<div class="card"><h3>밝히는 것</h3><p>누가 썼는지, 원장 감수를 거쳤는지, 무엇을 참고했는지, 그리고 이 사이트가 {clinic}의 지원을 받는다는 사실. <a href="/about">이 사이트에 대해</a></p></div>
</div>
</div>
</section>
<section class="section tint"><div class="wrap-wide"><div class="sec-head"><div class="eyebrow">Fact Sheet</div><h2>Clinic Facts</h2><p class="sub">주소·전화·진료시간. 방문 전에 전화로 한 번 더 확인을 권합니다.</p></div><FactBlock /></div></section>
</Base>
<style>
.card.entry { text-decoration: none; color: inherit; }
.card.entry:hover { text-decoration: none; }
.card.entry .more { font-size: 0.85rem; font-weight: 600; color: var(--accent); }
</style>

View File

@ -0,0 +1,124 @@
---
import Base from '../layouts/Base.astro';
import news from '../data/news.json';
import { SITE_NAME, fact, site as S, v, PENDING } from '../lib';
const clinic = v(fact.shortName, '병원');
const items = news.items as { date: string; title: string; outlet: string; url: string; kind: 'release' | 'column' | 'mention'; origin: string; topic: string }[];
const site = Astro.site!.toString().replace(/\/$/, '');
const years = [...new Set(items.map((i) => i.date.slice(0, 4)))].sort().reverse();
const topics = [...new Set(items.map((i) => i.topic))];
const topicCount = Object.fromEntries(topics.map((t) => [t, items.filter((i) => i.topic === t).length]));
topics.sort((a, b) => topicCount[b] - topicCount[a]);
const KIND: Record<string, string> = { release: '보도자료', column: '원장 기고·인터뷰', mention: '언론 언급' };
const counts = { release: items.filter((i) => i.kind === 'release').length, column: items.filter((i) => i.kind === 'column').length, mention: items.filter((i) => i.kind === 'mention').length };
const outlets = new Set(items.map((i) => i.outlet)).size;
const ld = [
{ '@type': 'CollectionPage', '@id': `${site}/newsroom#page`, name: `${clinic} 뉴스룸`, url: `${site}/newsroom`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(news.generatedAt ? { dateModified: news.generatedAt } : {}) },
{ '@type': 'ItemList', '@id': `${site}/newsroom#list`, name: `${clinic} 언론 보도 목록`, numberOfItems: items.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: items.slice(0, 200).map((i, idx) => ({ '@type': 'ListItem', position: idx + 1, url: i.url, name: i.title })) },
];
---
<Base title={`뉴스룸 · ${clinic} 언론 보도 모음`} description={items.length ? `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도 ${items.length}건을 연도와 주제별로 모았습니다. ${years[years.length - 1]}년부터 ${years[0]}년까지, 매체 ${outlets}곳. 제목을 누르면 기사 원문으로 이동합니다.` : `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모읍니다. 기사 수집 ${PENDING}.`} jsonLd={ld}>
<section class="section dark" style="padding:3.5rem 0 3rem">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Newsroom</div><h1 class="serif" style="font-size:2rem;color:#fff;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">In The News</h1><p class="sub">{clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모았습니다. 제목을 누르면 기사 원문으로 이동합니다.</p></div>
<div class="stats">
<div class="stat"><b>{items.length.toLocaleString()}</b><span class="label">기사</span><span class="desc">{years.length ? `${years[years.length - 1]}년부터 ${years[0]}년까지` : PENDING}</span></div>
<div class="stat"><b>{counts.release.toLocaleString()}</b><span class="label">보도자료</span><span class="desc">병원이 직접 알린 소식. 수상, 장비 도입, 행사, 협약</span></div>
<div class="stat"><b>{counts.column.toLocaleString()}</b><span class="label">원장 기고·인터뷰</span><span class="desc">원장이 언론에 직접 설명한 수술 이야기</span></div>
<div class="stat"><b>{outlets}</b><span class="label">매체</span><span class="desc">{v(S.newsOutletsExample, PENDING)}</span></div>
</div>
</div>
</section>
<section class="section light" style="padding-top:2.5rem">
<div class="wrap-wide">
<div class="filters" id="filters">
<div class="fgroup"><span class="flabel">종류</span>
<button class="chipbtn active" data-kind="all">전체</button>
<button class="chipbtn" data-kind="release">보도자료 {counts.release}</button>
<button class="chipbtn" data-kind="column">기고·인터뷰 {counts.column}</button>
<button class="chipbtn" data-kind="mention">언론 언급 {counts.mention}</button>
</div>
<div class="fgroup"><span class="flabel">주제</span>
<button class="chipbtn active" data-topic="all">전체</button>
{topics.map((t) => <button class="chipbtn" data-topic={t}>{t} {topicCount[t]}</button>)}
</div>
<div class="fgroup"><span class="flabel">연도</span>
<div class="yearnav">{years.map((y) => <a href={`#y${y}`}>{y}</a>)}</div>
</div>
</div>
{!items.length && <p class="sub" style="color:var(--slate-500)">병원 게시판과 뉴스 검색에서 기사를 모은 뒤 싣습니다. {PENDING}.</p>}
{years.map((y) => {
const list = items.filter((i) => i.date.startsWith(y));
return (
<section class="year" id={`y${y}`} data-year={y}>
<div class="yearhead"><h2 class="serif" style="margin-top:0">{y}</h2><span class="ycount">{list.length}건</span></div>
<ol class="newslist">
{list.map((i) => (
<li class="newsitem" data-kind={i.kind} data-topic={i.topic}>
<span class="ndate">{i.date}</span>
<span class={`nkind ${i.kind}`}>{KIND[i.kind]}</span>
<a class="ntitle" href={i.url} rel="noopener nofollow" target="_blank">{i.title}</a>
<span class="noutlet">{i.outlet}</span>
<span class="ntopic">{i.topic}</span>
</li>
))}
</ol>
</section>
);
})}
<p class="disclosure">{v(S.newsNote, `${clinic} 홈페이지 게시판과 뉴스 검색에서 모은 기사입니다. 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않습니다.`)}</p>
</div>
</section>
</Base>
<style>
.filters { display: grid; gap: 0.7rem; margin-bottom: 2rem; background: var(--primary-50); border-radius: 16px; padding: 1.1rem 1.25rem; }
.fgroup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; }
.flabel { font-size: 0.78rem; font-weight: 700; color: var(--slate-500); letter-spacing: 0.06em; margin-right: 0.3rem; min-width: 2.4em; }
.chipbtn { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.3rem 0.8rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; font-family: inherit; }
.chipbtn.active { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-color: transparent; }
.yearnav { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.yearnav a { font-size: 0.82rem; font-weight: 600; color: var(--accent); padding: 0.2rem 0.5rem; border-radius: 6px; }
.yearnav a:hover { background: var(--st-good-bg); text-decoration: none; }
.year { margin: 0 0 2.2rem; scroll-margin-top: 90px; }
.year.hidden { display: none; }
.yearhead { display: flex; align-items: baseline; gap: 0.8rem; border-bottom: 2px solid var(--primary-900); padding-bottom: 0.4rem; margin-bottom: 0.6rem; }
.yearhead h2 { margin: 0; font-size: 1.8rem; }
.ycount { color: var(--slate-500); font-size: 0.85rem; font-weight: 600; }
.newslist { list-style: none; padding: 0; margin: 0; }
.newsitem { display: grid; grid-template-columns: 6.2em 6.4em 1fr auto auto; gap: 0.6rem 0.9rem; align-items: baseline; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--slate-100); font-size: 0.93rem; }
.newsitem.hidden { display: none; }
.newsitem:hover { background: var(--primary-50); }
.ndate { color: var(--slate-500); font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.nkind { font-size: 0.72rem; font-weight: 700; border-radius: 999px; padding: 0.1rem 0.55rem; text-align: center; border: 1px solid transparent; white-space: nowrap; }
.nkind.release { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.nkind.column { background: var(--st-info-bg); color: var(--st-info-text); border-color: var(--st-info-border); }
.nkind.mention { background: var(--slate-100); color: var(--slate-600); }
.ntitle { color: var(--primary-900); font-weight: 500; }
.noutlet { color: var(--slate-500); font-size: 0.83rem; white-space: nowrap; }
.ntopic { color: var(--accent); font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
@media (max-width: 760px) { .newsitem { grid-template-columns: 1fr; gap: 0.15rem; } .noutlet, .ntopic { display: inline; margin-right: 0.6rem; } }
</style>
<script>
const state = { kind: 'all', topic: 'all' };
const items = Array.from(document.querySelectorAll<HTMLElement>('.newsitem'));
const years = Array.from(document.querySelectorAll<HTMLElement>('.year'));
function apply() {
for (const el of items) {
const ok = (state.kind === 'all' || el.dataset.kind === state.kind) && (state.topic === 'all' || el.dataset.topic === state.topic);
el.classList.toggle('hidden', !ok);
}
for (const y of years) {
const visible = y.querySelectorAll('.newsitem:not(.hidden)').length;
y.classList.toggle('hidden', visible === 0);
const c = y.querySelector('.ycount'); if (c) c.textContent = `${visible}건`;
}
}
document.querySelectorAll<HTMLButtonElement>('.chipbtn').forEach((b) => b.addEventListener('click', () => {
const key = b.dataset.kind ? 'kind' : 'topic'; const val = (b.dataset.kind ?? b.dataset.topic)!;
(state as any)[key] = val;
b.parentElement!.querySelectorAll('.chipbtn').forEach((x) => x.classList.toggle('active', x === b));
apply();
}));
</script>

View File

@ -0,0 +1,94 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import Summary from '../../components/Summary.astro';
import VideoLite from '../../components/VideoLite.astro';
import RelatedVideos from '../../components/RelatedVideos.astro';
import Gallery from '../../components/Gallery.astro';
import Faq from '../../components/Faq.astro';
import ReviewBox from '../../components/ReviewBox.astro';
import Sources from '../../components/Sources.astro';
import History from '../../components/History.astro';
import Disclosure from '../../components/Disclosure.astro';
import FactBlock from '../../components/FactBlock.astro';
import { CATEGORY_LABELS, SITE_NAME, clinicSchema, personSchema, physicianSchema, physician, supporter, fact, surface } from '../../lib';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
const d = post.data;
const { Content } = await render(post);
const site = Astro.site!.toString().replace(/\/$/, '');
const url = `${site}/posts/${post.id}`;
const videos = d.videos.length ? d.videos : d.video ? [d.video] : [];
const [mainVideo, ...moreVideos] = videos;
const reviewer = d.reviewer ? physician(d.reviewer) : null;
const article: Record<string, any> = {
'@type': ['Article', 'MedicalWebPage'],
'@id': `${url}#article`,
headline: d.title,
description: d.description,
inLanguage: 'ko',
url,
mainEntityOfPage: url,
datePublished: d.datePublished,
dateModified: d.dateModified,
author: { '@id': `${site}/authors/${d.author}#person` },
publisher: { '@type': 'Organization', name: SITE_NAME, url: site },
about: { '@id': `${fact.url}/#clinic` },
image: [d.hero?.src, ...d.gallery.map((g) => g.src)].filter(Boolean).map((s) => `${site}${s}`),
};
if (!article.image.length && clinicSchema(site).image) article.image = [clinicSchema(site).image];
if (!article.image.length) delete article.image;
// 감수가 실제로 끝난 글에만 reviewedBy를 넣는다. 감수 예정 상태에서 넣으면 허위 표기다.
if (reviewer && d.reviewStatus === 'reviewed') { article.reviewedBy = { '@id': `${reviewer.url}#physician` }; if (d.reviewedAt) article.lastReviewed = d.reviewedAt; }
if (videos.length) article.video = videos.map((v) => ({ '@type': 'VideoObject', name: v.title, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, url: `https://www.youtube.com/watch?v=${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published ?? d.datePublished, description: v.title, publisher: { '@type': 'Organization', name: `${surface('youtube').title ?? fact.shortName} (YouTube)` } }));
const graph: any[] = [article, personSchema(d.author, site), clinicSchema(site)];
if (d.reviewer) graph.push(physicianSchema(d.reviewer));
if (d.faq.length) graph.push({ '@type': 'FAQPage', '@id': `${url}#faq`, mainEntity: d.faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });
---
<Base title={d.title} description={d.description} jsonLd={graph} type="article" ogImage={d.hero?.src}>
<article class="wrap" style="padding-bottom:4rem" data-qb={d.qbIds.join(',')}>
<header class="article-head">
<div class="eyebrow">{d.category} · {CATEGORY_LABELS[d.category]}</div>
<h1>{d.title}</h1>
<div class="meta">
<span>작성 {supporter(d.author).name}</span>
{reviewer && <span>{d.reviewStatus === 'reviewed' ? '의학 검토' : '설명'} {reviewer.name} {reviewer.title.split(' · ')[0]}{d.reviewStatus === 'reviewed' && d.reviewedAt ? ` (${d.reviewedAt})` : ''}</span>}
{d.reviewStatus !== 'reviewed' && <span>의학 검토 대기</span>}
<span>발행 {d.datePublished}</span>
<span>갱신 {d.dateModified}</span>
</div>
</header>
<Summary items={d.summary} />
{d.hero && (
<figure class="figure">
<img src={d.hero.src} alt={d.hero.alt} loading="eager" />
<figcaption>{d.hero.caption}</figcaption>
</figure>
)}
{mainVideo && <VideoLite {...mainVideo} />}
{reviewer && (
<div class="doctor-strip">
{reviewer.image && <img src={reviewer.image} alt={`${reviewer.name} ${reviewer.title}`} />}
<div>
<div class="name">{reviewer.name} <span style="font-weight:500;color:var(--slate-600)">{reviewer.title}</span></div>
<p class="creds">{reviewer.credentials.slice(0, 3).join(' · ')} <a href={reviewer.url} rel="noopener">프로필</a></p>
</div>
</div>
)}
<Content />
<Gallery items={d.gallery} />
<RelatedVideos items={moreVideos} title="이어서 볼 원장 영상" />
<Faq items={d.faq} />
<ReviewBox author={d.author} reviewer={d.reviewer} status={d.reviewStatus} dateModified={d.reviewedAt ?? d.dateModified} />
<Sources items={d.sources} />
<History items={d.history} />
<FactBlock compact />
<Disclosure />
</article>
</Base>

View File

@ -0,0 +1,41 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
import { CATEGORY_LABELS, SITE_NAME, fact, v } from '../../lib';
const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified));
const cats = Object.keys(CATEGORY_LABELS);
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'CollectionPage', '@id': `${site}/posts#page`, name: '전체 글', url: `${site}/posts`, inLanguage: 'ko', publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, hasPart: posts.map((p) => ({ '@type': 'Article', headline: p.data.title, url: `${site}/posts/${p.id}`, dateModified: p.data.dateModified })) };
---
<Base title="전체 글" description={`${v(fact.shortName, '병원')}에 대해 자주 묻는 질문과 답을 주제별로 모았습니다.`} jsonLd={ld}>
<div class="wrap-wide" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Archive</div><h1 class="serif">All Answers</h1><p style="color:var(--slate-600);margin-top:0.6rem">궁금한 주제를 골라 보세요. 글 하나가 질문 하나에 답합니다.</p></header>
{!posts.length && <p class="sub" style="color:var(--slate-500);margin-top:1.5rem">아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.</p>}
<div class="cat-grid">
{cats.map((c) => {
const list = posts.filter((p) => p.data.category === c);
if (!list.length) return null;
return (<section id={`cat-${c}`} class={`cat span-${Math.min(list.length, 3)}`}><div class="section-title"><h2>{c}. {CATEGORY_LABELS[c]}</h2><span style="color:var(--slate-500);font-size:0.9rem">{list.length}편</span></div><div class="grid">{list.map((p) => <PostCard post={p} />)}</div></section>);
})}
</div>
</div>
</Base>
<style>
/* 카테고리 블록이 글 수만큼만 칸을 차지한다. 데스크톱 3열 → 태블릿 2열 → 모바일 1열 */
.cat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.4rem 1.25rem; margin-top: 2.4rem; align-items: start; }
.cat { scroll-margin-top: 90px; min-width: 0; }
.cat .section-title { margin-bottom: 1rem; }
.cat .grid { grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr)); }
.span-1 { grid-column: span 1; --cols: 1; }
.span-2 { grid-column: span 2; --cols: 2; }
.span-3 { grid-column: span 3; --cols: 3; }
@media (max-width: 1000px) {
.cat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.span-3 { grid-column: span 2; --cols: 2; }
}
@media (max-width: 660px) {
.cat-grid { grid-template-columns: 1fr; gap: 2rem; }
.span-2, .span-3 { grid-column: span 1; --cols: 1; }
}
</style>

View File

@ -0,0 +1,10 @@
import type { APIRoute } from 'astro';
// 샘플: 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용.
const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot'];
export const GET: APIRoute = ({ site }) => {
const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true';
const body = indexable
? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n'
: `# 샘플 사이트. 색인 금지.\nUser-agent: *\nDisallow: /\n`;
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
};

View File

@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import authors from '../data/authors.json';
export const GET: APIRoute = async ({ site }) => {
const base = site!.toString().replace(/\/$/, '');
const posts = await getCollection('posts');
const today = new Date().toISOString().slice(0, 10);
const urls = [
{ loc: `${base}/`, lastmod: today },
{ loc: `${base}/posts`, lastmod: today },
{ loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today },
{ loc: `${base}/videos`, lastmod: today },
{ loc: `${base}/about`, lastmod: today },
{ loc: `${base}/corrections`, lastmod: today },
...Object.keys(authors.supporters).map((id) => ({ loc: `${base}/authors/${id}`, lastmod: today })),
...posts.map((p) => ({ loc: `${base}/posts/${p.id}`, lastmod: p.data.dateModified })),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${u.loc}</loc><lastmod>${u.lastmod}</lastmod></url>`).join('\n')}\n</urlset>\n`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};

View File

@ -0,0 +1,109 @@
---
import Base from '../layouts/Base.astro';
import VideoLite from '../components/VideoLite.astro';
import data from '../data/videos.json';
import { SITE_NAME, fact, surface, v, has, PENDING } from '../lib';
const clinic = v(fact.shortName, '병원');
const gu = surface('gangnamunni');
type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number };
const site = Astro.site!.toString().replace(/\/$/, '');
const ch = (data.channel ?? {}) as Record<string, any>;
const topLong = (data.topLong ?? []) as V[]; const topAll = (data.top ?? []) as V[];
const fetchedAt = data.fetchedAt ?? PENDING;
const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString();
const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[];
const tabs: { key: string; label: string; desc: string; list: V[] }[] = [
{ key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong },
{ key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts },
{ key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: topAll },
];
const ld = [
{ '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) },
{ '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topAll.length, itemListElement: topAll.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) },
];
---
<Base title={`의료진 영상 · ${clinic} 원장이 직접 설명하는 영상`} description={`${clinic} 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${fetchedAt} 기준.`} jsonLd={ld}>
<section class="section dark" style="padding:3.5rem 0 3rem">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Videos</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Most Watched</h1><p class="sub">원장이 수술 방법과 안전을 직접 설명하는 영상을 골라 모았습니다. 궁금한 영상을 여기서 바로 재생할 수 있습니다.</p>
<div class="doc-links">
<a class="doc-link" href={(fact as any).doctorsUrl || fact.url} rel="noopener" target="_blank">
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"/></svg></span>
<span class="txt"><b>의료진 소개</b><small>{clinic} 공식 홈페이지</small></span>
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
</a>
<a class="doc-link" href={gu.url || fact.url} rel="noopener" target="_blank">
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 6v6c0 5.3 3.8 10.2 9 11.5C17.2 22.2 21 17.3 21 12V6l-9-4Zm-1 14-3.5-3.5 1.4-1.4L11 13.2l5.1-5.1 1.4 1.4L11 16Z"/></svg></span>
<span class="txt"><b>의료진 {v(gu.doctors)}명</b><small>강남언니 등록 의료진</small></span>
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
</a>
</div></div>
<div class="stats">
<div class="stat"><b>{has(ch.totalViews) ? fmt(ch.totalViews) : PENDING}</b><span class="label">누적 조회수</span><span class="desc">공식 유튜브 채널 영상 {has(ch.videos) ? ch.videos.toLocaleString() : PENDING}편 합계 ({fetchedAt} 기준)</span></div>
<div class="stat"><b>{v(ch.subscribers)}</b><span class="label">구독자</span><span class="desc">{has(ch.url) ? <a href={ch.url} rel="noopener" style="color:var(--purple-300)">{ch.handle}</a> : PENDING}</span></div>
<div class="stat"><b>{topLong[0] ? fmt(topLong[0].views) : PENDING}</b><span class="label">설명 영상 1위 조회수</span><span class="desc">{topLong[0]?.title ?? '채널 영상 집계 뒤 표시'}</span></div>
<div class="stat"><b>{shorts.length}</b><span class="label">정보형 쇼츠</span><span class="desc">궁금한 것 하나에 1분 안에 답하는 영상. 서포터즈가 답을 글로도 정리했습니다.</span></div>
</div>
</div>
</section>
<section class="section light" style="padding-top:2.2rem">
<div class="wrap-wide">
<div class="vtabs" role="tablist">
{tabs.map((t, i) => <button class={`chipbtn ${i === 0 ? 'active' : ''}`} role="tab" data-tab={t.key} aria-selected={i === 0}>{t.label}</button>)}
</div>
{tabs.map((t, i) => (
<div class="vpanel" data-panel={t.key} hidden={i !== 0}>
<p class="sub" style="color:var(--slate-600);margin:0.8rem 0 1.2rem">{t.desc}. 조회수는 {fetchedAt} 기준입니다.</p>
{!t.list.length && <p class="sub" style="color:var(--slate-500)">채널 영상을 집계한 뒤 싣습니다. {PENDING}.</p>}
<ol class="vlist">
{t.list.map((v, idx) => (
<li class="vitem">
<div class="vrank serif">{idx + 1}</div>
<div class="vmedia"><VideoLite id={v.id} title={v.title} published={v.published} compact /></div>
<div class="vmeta">
<div class="vstats"><b>{fmt(v.views)}회</b> 조회{v.likes != null && <> · 좋아요 {fmt(v.likes)}</>}{v.comments != null && <> · 댓글 {v.comments.toLocaleString()}</>}</div>
<div class="vsub">{(v as any).isShort && <span class="nkind" style="background:var(--slate-100);color:var(--slate-600);font-size:0.7rem;font-weight:700;border-radius:999px;padding:0.05rem 0.5rem;margin-right:0.4rem">쇼츠</span>}{dur(v.duration)} · {v.published} 게시 · <a href={`https://www.youtube.com/watch?v=${v.id}`} rel="noopener" target="_blank">유튜브에서 보기</a></div>
{(v as any).answer && <div class="vanswer"><span class="vq">서포터즈 답</span><p>{(v as any).answer}</p></div>}
</div>
</li>
))}
</ol>
</div>
))}
<p class="disclosure">영상은 {clinic} 공식 유튜브 채널에서 재생됩니다. 조회수·좋아요·댓글 수는 {fetchedAt} 기준입니다.</p>
</div>
</section>
</Base>
<style>
.doc-links { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 1.3rem; }
.doc-link { display: inline-flex; align-items: center; gap: 0.7rem; padding: 0.6rem 1rem 0.6rem 0.75rem; border-radius: 999px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #fff; text-decoration: none; transition: background .2s, border-color .2s, transform .2s; }
.doc-link:hover { background: rgba(255,255,255,0.14); border-color: rgba(216,180,254,0.6); text-decoration: none; transform: translateY(-1px); }
.doc-link .ico { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), #6C5CE7); color: #fff; flex: none; }
.doc-link .txt { display: flex; flex-direction: column; line-height: 1.2; }
.doc-link .txt b { font-size: 0.92rem; font-weight: 700; }
.doc-link .txt small { font-size: 0.74rem; color: var(--purple-200); margin-top: 0.15rem; }
.doc-link .arrow { color: var(--purple-300); display: inline-flex; margin-left: 0.2rem; }
.vtabs { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.chipbtn { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.4rem 1rem; font-size: 0.88rem; font-weight: 600; cursor: pointer; font-family: inherit; }
.chipbtn.active { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-color: transparent; }
.vlist { list-style: none; padding: 0; margin: 0; display: grid; gap: 1.1rem; }
.vitem { display: grid; grid-template-columns: 3.2rem 360px 1fr; gap: 1.1rem; align-items: start; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; box-shadow: var(--shadow); }
.vrank { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; padding-top: 0.2rem; }
.vmeta { padding-top: 0.2rem; }
.vstats { font-size: 1rem; color: var(--primary-900); }
.vstats b { font-size: 1.25rem; font-family: "Playfair Display", Georgia, serif; }
.vsub { color: var(--slate-500); font-size: 0.86rem; margin-top: 0.3rem; }
.vanswer { margin-top: 0.8rem; background: #F2F6FF; border: 1px solid #E3EAFB; border-radius: 12px; padding: 0.8rem 1rem; }
.vanswer .vq { font-size: 0.72rem; font-weight: 700; color: var(--grad-start); letter-spacing: 0.06em; }
.vanswer p { margin: 0.25rem 0 0; font-size: 0.93rem; color: var(--primary-900); }
.vanswer .vqb { display: inline-block; margin-top: 0.4rem; font-size: 0.72rem; color: var(--accent); font-weight: 600; }
.vmedia .yt figcaption span { display: none; }
@media (max-width: 860px) { .vitem { grid-template-columns: 2.4rem 1fr; } .vmeta { grid-column: 2; } }
</style>
<script>
document.querySelectorAll<HTMLButtonElement>('.vtabs .chipbtn').forEach((b) => b.addEventListener('click', () => {
document.querySelectorAll('.vtabs .chipbtn').forEach((x) => { x.classList.toggle('active', x === b); x.setAttribute('aria-selected', String(x === b)); });
document.querySelectorAll<HTMLElement>('.vpanel').forEach((p) => { p.hidden = p.dataset.panel !== b.dataset.tab; });
}));
</script>

View File

@ -0,0 +1,47 @@
---
// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다.
import Base from '../layouts/Base.astro';
import Gallery from '../components/Gallery.astro';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [
{ '@type': 'WebPage', '@id': `${site}/visit#page`, name: `${clinic} 방문 안내`, url: `${site}/visit`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base title="방문 안내 · 예약, 가는 길, 주차, 도착 후 동선" description={`${clinic} 방문 준비. 예약 경로, 가는 길, 주차장 진입 경로, 진료시간, 도착 후 동선을 순서대로 안내합니다. 주소 ${v(f.address?.full)}.`} jsonLd={ld}>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head"><div class="eyebrow">Visit</div><h1 class="serif">Plan Your Visit</h1><p style="color:var(--slate-600);margin-top:0.6rem"><strong style="color:var(--primary-900)">{clinic} 방문 안내.</strong> 예약부터 도착 후 동선까지 순서대로 정리했습니다. 주소·전화·진료시간의 근거는 <a href="/clinic">병원 정보</a>에 있습니다.</p></header>
<h2>1. 예약하기</h2>
<p>상담 예약 경로는 세 가지입니다. 어느 쪽이든 같은 병원 상담센터로 연결됩니다.</p>
<div class="table-wrap"><table><thead><tr><th>경로</th><th>방법</th></tr></thead><tbody>
<tr><td>전화</td><td>{has(f.phone) ? <a href={`tel:${f.phone.replace(/-/g, '')}`}>{f.phone}</a> : v(null)}</td></tr>
<tr><td>카카오톡 채널</td><td>{has(f.kakao) ? <a href={f.kakao} rel="noopener">{clinic} 상담 채널</a> : v(null)}</td></tr>
<tr><td>홈페이지</td><td>{has(reservationUrl) ? <a href={reservationUrl} rel="noopener">온라인 예약 페이지</a> : v(null)}</td></tr>
</tbody></table></div>
<h2>2. 진료시간 확인하기</h2>
<div class="table-wrap"><table><thead><tr><th>요일</th><th>진료</th></tr></thead><tbody>{(f.hours?.rows ?? []).map((r: any) => <tr><td>{r.days}</td><td>{r.open ? `${r.open} ~ ${r.close}` : r.note}</td></tr>)}{!(f.hours?.rows ?? []).length && <tr><td colspan="2">{v(null)}</td></tr>}</tbody></table></div>
<p style="font-size:0.88rem;color:var(--slate-500)">출처: {has(f.hours?.source) ? <a href={f.hours.source} rel="noopener">{f.hours.sourceLabel}</a> : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.</p>
<h2>3. 찾아가기</h2>
<p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p>
<p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p>
<p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p>
{S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />}
<h2>4. 도착하면</h2>
<p>{v(S.arrivalGuide, `도착 후 동선은 병원 확인 뒤 싣습니다. ${v(null)}.`)} 층별 구성 전체는 <a href="/clinic">병원 정보</a>에 있습니다.</p>
<h2>5. 상담 전에 읽어볼 글</h2>
<ul>
{(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)}
{!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
</ul>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p>
<p class="disclosure">{fact.sideEffectNotice}</p>
</article>
</Base>

View File

@ -0,0 +1,264 @@
/* INFINITH Design Tokens (docs/DESIGN_SYSTEM.md) 적용.
원색 금지 · 라인 아이콘 금지 · 이모지 금지 · 다크/라이트 섹션 리듬 · 다크 위 흰 카드 · 대각선 그림자 · 파스텔 상태색 */
:root {
--primary-900: #0A1128;
--primary-800: #1A2B5E;
--primary-50: #F4F6FB;
--accent: #6C5CE7;
--grad-start: #4F1DA1;
--grad-end: #021341;
--slate-700: #334155; --slate-600: #475569; --slate-500: #64748B; --slate-200: #E2E8F0; --slate-100: #F1F5F9;
--purple-200: #E9D5FF; --purple-300: #D8B4FE; --blue-300: #93C5FD;
--st-critical-bg: #FFF0F0; --st-critical-text: #7C3A4B; --st-critical-border: #F5D5DC;
--st-warning-bg: #FFF6ED; --st-warning-text: #7C5C3A; --st-warning-border: #F5E0C5;
--st-good-bg: #F3F0FF; --st-good-text: #4A3A7C; --st-good-border: #D5CDF5;
--st-info-bg: #EFF0FF; --st-info-text: #3A3F7C; --st-info-border: #C5CBF5;
--shadow: 3px 4px 12px rgba(0,0,0,0.06);
--shadow-hover: 4px 6px 16px rgba(0,0,0,0.09);
--max: 760px; --wide: 1280px;
font-size: 17px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: #fff; color: var(--primary-900);
font-family: Pretendard, "Pretendard Variable", Inter, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", system-ui, sans-serif;
line-height: 1.7; word-break: keep-all; -webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; display: block; }
h1, h2, h3 { line-height: 1.3; margin: 0; color: var(--primary-900); }
h1 { font-size: 2.1rem; font-weight: 800; letter-spacing: -0.02em; }
h2 { font-size: 1.45rem; font-weight: 700; margin-top: 2.8rem; margin-bottom: 0.8rem; letter-spacing: -0.01em; }
h3 { font-size: 1.12rem; font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.5rem; }
.serif { font-family: "Playfair Display", Georgia, serif; }
p { margin: 0 0 1rem; color: var(--slate-700); }
ul, ol { padding-left: 1.3rem; margin: 0 0 1rem; color: var(--slate-700); }
li { margin-bottom: 0.35rem; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 1rem 0 1.5rem; font-size: 0.95rem; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); }
th, td { padding: 0.7rem 0.9rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--slate-100); }
tr:last-child td { border-bottom: 0; }
th { background: var(--primary-900); color: #fff; font-weight: 600; font-size: 0.88rem; letter-spacing: 0.02em; }
td { color: var(--slate-700); }
.table-wrap { overflow-x: auto; }
.wrap { max-width: var(--max); margin: 0 auto; padding: 0 1.5rem; }
.wrap-wide { max-width: var(--wide); margin: 0 auto; padding: 0 1.5rem; }
/* sections: dark / light rhythm */
.section { padding: 4rem 0; }
.section.dark { background: var(--primary-900); background-image: radial-gradient(ellipse at 20% 0%, rgba(108,92,231,0.18), transparent 55%); color: #fff; }
.section.dark h1, .section.dark h2, .section.dark h3 { color: #fff; }
.section.dark p { color: rgba(255,255,255,0.8); }
.section.light { background: #fff; }
.section.tint { background: var(--primary-50); }
.grad-text { background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; }
/* header */
.site-header { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,0.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--slate-100); }
.site-header .bar { display: flex; align-items: center; justify-content: space-between; height: 68px; }
.brand { display: flex; align-items: center; gap: 0.6rem; color: var(--primary-900); font-weight: 800; font-size: 1.1rem; }
.brand .mark { display: none; }
.brand-logo { height: 40px; width: auto; display: block; }
.brand-name { height: auto; font-weight: 800; font-size: 1.05rem; color: var(--primary-900); letter-spacing: -0.01em; white-space: nowrap; }
.brand-sep { width: 1px; height: 26px; background: var(--slate-200); margin: 0 0.35rem; }
.brand-text { display: flex; align-items: baseline; gap: 0.45rem; }
.brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; }
.nav a { color: var(--slate-600); margin-left: 1.3rem; font-size: 0.93rem; font-weight: 500; }
.nav a:hover { color: var(--primary-900); text-decoration: none; }
.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.83rem; text-align: center; padding: 0.5rem 1rem; font-weight: 500; }
/* pills & badges */
.pill { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; }
.pill.grad { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; }
.pill.ghost { background: rgba(255,255,255,0.1); color: var(--purple-200); border: 1px solid rgba(255,255,255,0.15); }
.pill.soft { background: var(--st-good-bg); color: var(--st-good-text); border: 1px solid var(--st-good-border); }
.badge { display: inline-block; font-size: 0.74rem; font-weight: 600; padding: 0.15rem 0.6rem; border-radius: 999px; margin-left: 0.4rem; vertical-align: middle; border: 1px solid transparent; }
.badge.reviewed { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.badge.pending { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); }
.btn { display: inline-flex; align-items: center; gap: 0.5rem; border-radius: 999px; padding: 0.75rem 1.4rem; font-weight: 600; font-size: 0.95rem; text-decoration: none; }
.btn.primary { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; box-shadow: var(--shadow); }
.btn.secondary { background: #fff; color: var(--grad-end); border: 1px solid var(--slate-200); }
.btn:hover { text-decoration: none; box-shadow: var(--shadow-hover); }
/* article */
.eyebrow { color: var(--accent); font-weight: 700; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 0.7rem; font-family: Inter, Pretendard, sans-serif; }
.article-head { padding: 3rem 0 1.2rem; }
.meta { color: var(--slate-500); font-size: 0.88rem; display: flex; flex-wrap: wrap; gap: 0.4rem 1.1rem; margin-top: 1rem; }
.summary { background: #F2F6FF; border: 1px solid #E3EAFB; border-radius: 16px; padding: 1.3rem 1.5rem; margin: 1.6rem 0 2.2rem; box-shadow: var(--shadow); }
.summary h2 { margin: 0 0 0.6rem; font-size: 0.8rem; color: var(--grad-start); letter-spacing: 0.08em; text-transform: uppercase; font-family: Inter, Pretendard, sans-serif; }
.summary ol { margin: 0; padding-left: 1.2rem; }
.summary li { margin-bottom: 0.35rem; font-weight: 500; color: var(--primary-900); }
.figure { margin: 1.8rem 0; }
.figure img { border-radius: 16px; box-shadow: var(--shadow); }
.figure figcaption { font-size: 0.83rem; color: var(--slate-500); margin-top: 0.6rem; }
.video { position: relative; aspect-ratio: 16 / 9; background: var(--primary-900); border-radius: 16px; overflow: hidden; margin: 0.6rem 0 0.5rem; box-shadow: var(--shadow); }
.video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.video-note { font-size: 0.85rem; color: var(--slate-500); margin-bottom: 1.4rem; }
.faq details { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 0.95rem 1.2rem; margin-bottom: 0.6rem; box-shadow: var(--shadow); }
.faq summary { cursor: pointer; font-weight: 700; list-style: none; color: var(--primary-900); }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::before { content: "Q"; display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 999px; background: var(--st-good-bg); color: var(--accent); font-size: 0.75rem; margin-right: 0.6rem; font-family: Inter, sans-serif; }
.faq details p { margin: 0.7rem 0 0 2.1rem; color: var(--slate-600); }
.review-box { display: grid; grid-template-columns: 56px 1fr; gap: 1rem; align-items: start; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.1rem 1.25rem; margin: 2.4rem 0 1rem; font-size: 0.92rem; box-shadow: var(--shadow); color: var(--slate-700); }
.review-box img { width: 56px; height: 56px; object-fit: cover; border-radius: 50%; }
.review-box .avatar { width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, var(--grad-start), var(--grad-end)); display: grid; place-items: center; color: #fff; font-weight: 800; }
.sources { font-size: 0.88rem; color: var(--slate-600); }
.sources li { margin-bottom: 0.3rem; }
.disclosure { font-size: 0.84rem; color: var(--slate-500); border-top: 1px solid var(--slate-100); padding-top: 1.1rem; margin-top: 2.2rem; }
.fact-block { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.5rem; margin: 2.4rem 0; font-size: 0.93rem; box-shadow: var(--shadow); }
.fact-block h2 { margin: 0 0 0.8rem; font-size: 1rem; }
.fact-block dl { display: grid; grid-template-columns: 7.5em 1fr; gap: 0.4rem 0.9rem; margin: 0; }
.fact-block dt { color: var(--slate-500); }
.fact-block dd { margin: 0; color: var(--slate-700); }
.section.dark .fact-block { color: var(--slate-700); }
/* home */
.hero { padding: 5rem 0 4rem; }
.hero h1 { font-size: 2.6rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.2; margin-top: 1rem; }
.hero .lede { font-size: 1.1rem; color: rgba(255,255,255,0.8); margin-top: 1.2rem; max-width: 680px; }
.hero .actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 1.6rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.25rem; }
.card { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.4rem 1.5rem; display: flex; flex-direction: column; gap: 0.55rem; box-shadow: var(--shadow); transition: box-shadow .2s, transform .2s; }
.card:hover { box-shadow: var(--shadow-hover); transform: translateY(-2px); }
.card .cat { font-size: 0.75rem; color: var(--accent); font-weight: 700; letter-spacing: 0.06em; font-family: Inter, Pretendard, sans-serif; }
.card h3 { margin: 0; font-size: 1.08rem; color: var(--primary-900); }
.card h3 a { color: var(--primary-900); }
.card p { color: var(--slate-600); font-size: 0.93rem; margin: 0; flex: 1; }
.card .foot { font-size: 0.78rem; color: var(--slate-500); }
.section.dark .card p, .section.dark .card h3, .section.dark .card h3 a { color: var(--primary-900); }
.section.dark .card p { color: var(--slate-600); }
.section-title { display: flex; align-items: baseline; justify-content: space-between; margin: 0 0 1.4rem; }
.section-title h2 { margin: 0; font-size: 1.7rem; }
.section-title .sub { color: var(--slate-600); font-size: 1rem; margin: 0.3rem 0 0; }
.section.dark .section-title .sub { color: var(--purple-200); }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin: 2rem 0 0; }
.kpi { background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); border-radius: 16px; padding: 1.1rem 1.2rem; box-shadow: var(--shadow); }
.kpi b { display: block; font-size: 1.6rem; letter-spacing: -0.02em; color: var(--primary-900); font-family: "Playfair Display", Georgia, serif; }
.kpi span { font-size: 0.8rem; color: var(--slate-600); font-weight: 500; }
.notice { background: var(--st-warning-bg); color: var(--st-warning-text); border: 1px solid var(--st-warning-border); border-radius: 16px; padding: 0.95rem 1.2rem; font-size: 0.9rem; margin: 1.4rem 0; }
/* footer */
.site-footer { margin-top: 0; background: var(--primary-900); color: rgba(255,255,255,0.75); padding: 3rem 0 3rem; font-size: 0.85rem; }
.site-footer .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
.site-footer p { margin-bottom: 0.5rem; color: rgba(255,255,255,0.75); }
.site-footer strong { color: #fff; }
.site-footer a { color: var(--purple-300); }
.site-footer .powered { margin-top: 2rem; padding-top: 1.2rem; border-top: 1px solid rgba(255,255,255,0.1); font-family: "Playfair Display", Georgia, serif; letter-spacing: 0.05em; color: var(--purple-300); font-size: 0.8rem; }
@media (max-width: 720px) {
.brand-logo { height: 32px; }
.brand-text small { display: none; }
:root { font-size: 16px; }
h1 { font-size: 1.7rem; }
.hero h1 { font-size: 1.9rem; }
.hero { padding: 3.5rem 0 3rem; }
.section { padding: 3rem 0; }
.site-footer .cols { grid-template-columns: 1fr; }
.nav a { margin-left: 0.9rem; font-size: 0.86rem; }
.fact-block dl { grid-template-columns: 6em 1fr; }
}
/* ── INFINITH 실측 규격 보정 (plan/view-clinic · discovery 랜딩 기준) ── */
/* 히어로: 라이트 파스텔 라디얼 (PlanHeader.tsx 공식) */
.hero-light { position: relative; overflow: hidden; padding: 5rem 0 4.5rem;
background: radial-gradient(ellipse at top left, #e0e7ff, transparent 50%), radial-gradient(ellipse at bottom right, #fce7f3, transparent 50%), radial-gradient(ellipse at center, #f5f3ff, transparent 60%); }
.hero-light .blob { position: absolute; border-radius: 999px; filter: blur(60px); pointer-events: none; }
.hero-light .blob.a { top: 2rem; left: 2rem; width: 18rem; height: 18rem; background: rgba(108,92,231,0.10); }
.hero-light .blob.b { bottom: 2rem; right: 2rem; width: 24rem; height: 24rem; background: rgba(213,205,245,0.30); }
.hero-light .inner { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center; }
.hero-light h1 { font-size: 2.86rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.18; color: var(--primary-900); }
.hero-light h1 .accent { color: var(--accent); }
.hero-light .lede { font-size: 1.08rem; color: var(--slate-600); margin-top: 1.1rem; max-width: 640px; }
.hero-light .chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.3rem; }
.chip { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; background: rgba(255,255,255,0.6); backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.4); padding: 0.3rem 0.8rem; font-size: 0.85rem; font-weight: 500; color: var(--slate-700); }
.chip .dot { width: 6px; height: 6px; border-radius: 999px; background: var(--accent); }
.ring { width: 8.5rem; height: 8.5rem; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; box-shadow: 0 10px 25px rgba(2,19,65,0.25); }
.ring b { font-family: "Playfair Display", Georgia, serif; font-size: 2.6rem; line-height: 1; font-weight: 700; }
.ring span { font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--purple-200); margin-top: 0.3rem; font-family: Inter, sans-serif; }
/* 섹션 제목: 영문 Playfair + 한글 부제 (SectionWrapper 규격) */
.sec-head { margin-bottom: 1.6rem; }
.sec-head h2 { font-family: "Playfair Display", Georgia, serif; font-size: 2rem; font-weight: 700; margin: 0; letter-spacing: 0; }
.sec-head .sub { margin: 0.35rem 0 0; color: var(--slate-600); font-size: 1rem; }
.section.dark .sec-head h2 { background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block; }
.section.dark .sec-head .sub { color: var(--purple-200); }
.sec-head .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; }
.sec-head .eyebrow { margin-bottom: 0.5rem; }
/* 다크 섹션 KPI: 다크 글래스 카드 + Playfair 숫자 #AF90FF (WhyNow.tsx 규격) */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem; }
.stat { background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.10); backdrop-filter: blur(4px); border-radius: 16px; padding: 1.5rem 1.6rem; }
.stat b { display: block; font-family: "Playfair Display", Georgia, serif; font-size: 2.6rem; font-weight: 700; line-height: 1.15; color: #AF90FF; }
.stat .label { display: block; color: #fff; font-weight: 700; font-size: 0.98rem; margin-top: 0.4rem; }
.stat .desc { display: block; color: rgba(255,255,255,0.7); font-size: 0.86rem; margin-top: 0.35rem; line-height: 1.6; }
/* 라이트 섹션 카드: 표준 카드(bg-white · border-slate-100 · shadow-sm) */
.section.light .card { box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.section.light .card:hover { box-shadow: var(--shadow-hover); }
/* 헤더: INFINITH 헤더 규격 (흰 배경, 우측 그라디언트 pill CTA) */
.site-header .right { display: flex; align-items: center; gap: 1rem; }
.site-header .cta { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-radius: 999px; padding: 0.5rem 1.05rem; font-size: 0.86rem; font-weight: 600; }
.site-header .cta:hover { text-decoration: none; box-shadow: var(--shadow); }
.nav a, .site-header .cta, .brand-text { white-space: nowrap; }
/* 720~1040px: 메뉴 5개가 한 줄에 들어가도록 간격·크기 축소, 브랜드 영문 서브 숨김 */
@media (max-width: 1040px) {
.nav a { margin-left: 0.85rem; font-size: 0.86rem; }
.site-header .right { gap: 0.7rem; }
.site-header .cta { padding: 0.45rem 0.85rem; font-size: 0.82rem; }
.brand-text small { display: none; }
}
@media (max-width: 720px) {
.hero-light .inner { grid-template-columns: 1fr; }
.hero-light h1 { font-size: 1.9rem; }
.ring { display: none; }
.site-header .cta { display: none; }
.sec-head h2 { font-size: 1.6rem; }
}
/* ── 미디어: 라이트 유튜브 임베드 · 갤러리 · 썸네일 카드 ── */
.yt { margin: 1.4rem 0 1.8rem; }
.yt-btn { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; padding: 0; border: 0; border-radius: 16px; overflow: hidden; cursor: pointer; background: var(--primary-900); box-shadow: var(--shadow); }
.yt-btn img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s; }
.yt-btn:hover img { transform: scale(1.03); }
.yt-btn .play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 64px; height: 64px; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: grid; place-items: center; box-shadow: 0 8px 24px rgba(2,19,65,0.35); }
.yt-frame { aspect-ratio: 16 / 9; border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); background: #000; }
.yt-frame iframe { width: 100%; height: 100%; border: 0; display: block; }
.yt figcaption { margin-top: 0.6rem; font-size: 0.86rem; color: var(--slate-500); display: flex; flex-direction: column; gap: 0.1rem; }
.yt figcaption strong { color: var(--primary-900); font-size: 0.95rem; }
.yt.compact { margin: 0; }
.yt.compact .play { width: 48px; height: 48px; }
.yt.compact figcaption strong { font-size: 0.9rem; }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1.1rem; margin: 0.8rem 0 1.6rem; }
.gallery { display: grid; gap: 1rem; margin: 1.4rem 0 1.8rem; }
.gallery.cols-2 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.gallery.cols-3 { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.gallery .figure { margin: 0; }
.gallery .figure img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; }
.figure.portrait img { aspect-ratio: auto; }
.card.has-thumb { padding: 0; overflow: hidden; }
.card.has-thumb .thumb img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
.card.has-thumb .body { padding: 1.2rem 1.4rem 1.4rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.card.has-thumb .body p { flex: 1; }
.doctor-strip { display: flex; gap: 1rem; align-items: center; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; margin: 1.4rem 0; box-shadow: var(--shadow); }
.doctor-strip img { width: 72px; height: 92px; object-fit: cover; object-position: top; border-radius: 12px; }
.doctor-strip .name { font-weight: 800; color: var(--primary-900); }
.doctor-strip .creds { font-size: 0.85rem; color: var(--slate-600); margin: 0.2rem 0 0; }
.hero-light .hero-img { border-radius: 16px; overflow: hidden; box-shadow: 0 10px 30px rgba(2,19,65,0.18); max-width: 440px; }
.hero-light .hero-img img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.hero-light .hero-img.square { max-width: 420px; }
.hero-light .hero-img.collage { max-width: 500px; }
.hero-light .hero-img.collage img { aspect-ratio: 500 / 425; }
.hero-light .hero-img.square img { aspect-ratio: 1 / 1; }
@media (max-width: 720px) { .hero-light .hero-img { max-width: 100%; } }
/* ── 이전 페이지 버튼 · 플로팅 맨 위로 ── */
.backbar-wrap { background: #fff; border-bottom: 1px solid var(--slate-100); }
.backbar { padding: 0.8rem 1.5rem; }
.backbtn { display: inline-flex; align-items: center; gap: 0.35rem; background: #fff; border: 1px solid var(--slate-200); color: var(--grad-end); border-radius: 999px; padding: 0.4rem 0.95rem 0.4rem 0.7rem; font-size: 0.85rem; font-weight: 600; box-shadow: var(--shadow); }
.backbtn:hover { text-decoration: none; box-shadow: var(--shadow-hover); }
.totop { position: fixed; right: 1.4rem; bottom: 1.6rem; width: 48px; height: 48px; border: 0; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: grid; place-items: center; cursor: pointer; box-shadow: 0 8px 24px rgba(2,19,65,0.30); opacity: 0; transform: translateY(12px); pointer-events: none; transition: opacity .2s, transform .2s, box-shadow .2s; z-index: 20; }
.totop.show { opacity: 1; transform: none; pointer-events: auto; }
.totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); }
@media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } }

View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@ -0,0 +1,25 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "astro",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"build": {
"env": {
"SITE_URL": "https://supporters-__CLINIC_ID__.vercel.app",
"PUBLIC_INDEXABLE": "false"
}
},
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Robots-Tag",
"value": "noindex, nofollow"
}
]
}
],
"cleanUrls": true,
"trailingSlash": false
}