From 891b7bdecea492bc614c5601ddad86b21ca8530c Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 15:28:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(supporters):=20=EC=9B=8C=EC=BB=A4=20publis?= =?UTF-8?q?h=20=EB=8B=A8=EA=B3=84=20=C2=B7=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=89=EC=9D=B8=20=EC=9E=91=EC=97=85=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=ED=99=94=20+=20=EC=82=AC=EB=9E=8C=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/publish_site.mjs: 라이브 확인(홈·robots·sitemap·noindex), IndexNow(빙·네이버) 사이트맵 URL 알림, Google Search Console 사이트맵 제출·URL 색인 상태(서비스 계정 있을 때), 사람 몫 체크리스트(publish-checklist.md, status.humanTasks) - 워커: data 단계에서 IndexNow 키 파일 생성, --indexable(noindex 헤더 제거·승인 글만), --domain(vercel domains add·SITE_URL), deploy 뒤 publish 단계, 상태 published_pending_tasks - Base.astro: google/naver 소유 확인 메타(site.json googleSiteVerification·naverSiteVerification) - 완료 정의: 배포가 아니라 색인 작업과 사람 체크리스트까지 (haewon 2026-09-11) Co-Authored-By: Claude Fable 5.1 --- docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md | 4 +- supporters/scripts/publish_site.mjs | 127 ++++++++++++++ supporters/scripts/template/data/site.json | 6 +- supporters/src/data/site.json | 159 +++++++++++++++--- supporters/src/layouts/Base.astro | 2 + .../supporters-astro/scripts/publish_site.mjs | 127 ++++++++++++++ templates/supporters-astro/src/data/site.json | 6 +- .../supporters-astro/src/layouts/Base.astro | 2 + workers/supporters-build/run.mjs | 35 +++- 9 files changed, 434 insertions(+), 34 deletions(-) create mode 100644 supporters/scripts/publish_site.mjs create mode 100644 templates/supporters-astro/scripts/publish_site.mjs diff --git a/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md b/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md index 0dcae18..e85d603 100644 --- a/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md +++ b/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md @@ -54,11 +54,13 @@ INFINITH 저장소. 자동 빌드(워커·템플릿)는 워크트리 ~/orca/work ``` node workers/supporters-build/run.mjs --clinic --url https://<병원 홈페이지> --name "<상호>" [--industry derm] [--deploy] ``` - 단계 순서: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → **planner** → inputs → briefs → generate → build → deploy. 부분 재실행은 `--from <단계>`. 생성 비용 약 $0.6/병원(글 6~8편). + 단계 순서: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → **planner** → inputs → briefs → generate → build → deploy → **publish**. 부분 재실행은 `--from <단계>`. 생성 비용 약 $0.6/병원(글 6~8편). 4. **확인**: `~/supporters-builds//status.json` 의 `report.tourism`(좌표·장소 수)·`report.planner`(붙은 원문 문장 수)·`report.recovery`. 빌드 게이트 오류 0 인지. 5. **사람 게이트**: 의료진 후보 확정, 이미지 육안 확인, 구조 기준선 승인(`node scripts/check.mjs --update-layout`, 첫 빌드는 LAYOUT_NO_BASELINE 경고), 규칙표 provisional 값 원장 확인. 6. **모바일 검증**: 헤드리스 크롬은 창 최소 폭이 약 500px 이라 400px 직접 캡처가 부정확하다. 1280 창 안에 390px iframe 을 넣은 하네스로 찍는다(스크래치 `harness*.html` 방식, 홈·영상·방문·plan·뉴스룸·목록·글·병원 정보 8페이지). 7. **배포**: `--deploy` 또는 병원 폴더에서 `vercel --prod --yes`. 메인 `supporters/`(뷰 샘플)는 별도로 `git push` 후 `vercel --prod`. +8. **발행(색인)**: 배포 뒤 워커 `publish` 단계가 자동 실행된다(`scripts/publish_site.mjs`). 샘플(noindex) 상태면 라이브 확인과 "색인 허용 전환 결정" 체크리스트만 낸다. 정식 공개는 `--indexable --deploy [--domain <도메인>]` 로 재실행: noindex 헤더 제거, 승인 글만 발행, IndexNow 알림, GSC 사이트맵 제출(서비스 계정 있을 때). 결과는 `status.report.publish`, 사람 몫은 `~/supporters-builds//publish-checklist.md` 와 `status.humanTasks`. +9. **완료 판정**: 체크리스트의 사람 몫(네이버 서치어드바이저 등록·소유 확인·사이트맵 제출, GSC 소유자 추가, 도메인, 법무 3건, 백링크 요청)이 끝나야 "완료"다. 끝나기 전 상태는 `published_pending_tasks`. 기존 병원 재빌드(플래너·지도 포함 최신 템플릿 적용): `--from data` 로 돌리면 글·영상 집계는 보존되고 나머지는 새로 만든다. 원진처럼 원문 수집을 워커 밖에서 돌린 병원도 `recovery` 단계가 같은 결과를 낸다(`recoveryNotes.cache.json` 은 병원 폴더에 남아 번역 재사용). diff --git a/supporters/scripts/publish_site.mjs b/supporters/scripts/publish_site.mjs new file mode 100644 index 0000000..8a5f5f8 --- /dev/null +++ b/supporters/scripts/publish_site.mjs @@ -0,0 +1,127 @@ +// 배포 후 색인 작업 (워커 publish 단계). "서포터즈 사이트 완료"는 배포가 아니라 이 단계와 사람 체크리스트까지 끝난 상태다 (haewon 결정 2026-09-11). +// node scripts/publish_site.mjs --clinic --site --url https:// [--work ] [--indexable] +// +// 자동으로 하는 것 +// 1. 라이브 확인: 홈·robots.txt·sitemap.xml 응답, X-Robots-Tag(noindex) 여부, 사이트맵 URL 수 +// 2. IndexNow(빙·네이버 공용): 색인 허용 빌드일 때만 사이트맵의 URL 전부를 api.indexnow.org 에 알린다. 키 파일은 워커 data 단계가 public/.txt 로 넣는다 +// 3. Google Search Console: GOOGLE_SERVICE_ACCOUNT_JSON(경로 또는 JSON) 이 있으면 사이트맵 제출 + 최대 20 URL 색인 상태 조회(URL Inspection API). +// 서비스 계정이 속성 소유자로 등록돼 있지 않으면 403 → 사람 체크리스트로 넘긴다 +// 사람에게 남기는 것 (/publish-checklist.md, status.humanTasks) +// 네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유 확인(첫 1회), 도메인 연결, 색인 허용 전환 전 법무·승인 확인 +// 하지 않는 것: Google Indexing API(채용·라이브 방송 전용, 일반 페이지에 쓰면 가이드라인 위반), 색인 보장 약속. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createSign } from 'node:crypto'; + +const args = process.argv.slice(2); +const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; +const flag = (k) => args.includes(`--${k}`); +const clinic = opt('clinic'); const SITE = opt('site'); const URLBASE = (opt('url') || '').replace(/\/$/, ''); +if (!clinic || !SITE || !URLBASE) { console.error('사용법: --clinic --site --url https:// [--work ] [--indexable]'); process.exit(2); } +const WORK = resolve(opt('work', join(SITE, '..'))); +const site = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'site.json'), 'utf8')); +const fact = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'factSheet.json'), 'utf8')); +const today = new Date().toISOString().slice(0, 10); +const report = { clinic, url: URLBASE, checkedAt: new Date().toISOString(), live: {}, indexable: false, indexnow: null, google: null, naver: null, humanTasks: [] }; +const task = (id, title, why, how) => report.humanTasks.push({ id, title, why, how }); + +async function head(url) { + try { const r = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'infinith-supporters-publish/1.0' } }); const text = r.ok ? await r.text() : ''; return { ok: r.ok, status: r.status, robotsTag: r.headers.get('x-robots-tag') || '', text }; } + catch (e) { return { ok: false, status: 0, robotsTag: '', text: '', error: String(e.message ?? e) }; } +} + +// ---------- 1. 라이브 확인 ---------- +const home = await head(`${URLBASE}/`); +const robots = await head(`${URLBASE}/robots.txt`); +const sitemap = await head(`${URLBASE}/sitemap.xml`); +const urls = [...(sitemap.text.matchAll(/([^<]+)<\/loc>/g))].map((m) => m[1].trim()); +const noindexHeader = /noindex/i.test(home.robotsTag); +const robotsBlocksAll = /Disallow:\s*\/\s*$/m.test(robots.text) && !/Allow:\s*\//.test(robots.text); +report.live = { home: home.status, robots: robots.status, sitemap: sitemap.status, sitemapUrls: urls.length, noindexHeader, robotsBlocksAll }; +report.indexable = flag('indexable') || (home.ok && !noindexHeader && !robotsBlocksAll); +if (!home.ok) task('live', '라이브 응답 확인', `홈이 ${home.status} 로 응답했습니다.`, `${URLBASE}/ 를 열어 배포 상태를 확인`); + +// ---------- 2. IndexNow (빙·네이버) ---------- +if (report.indexable && urls.length) { + const key = site.indexNowKey; + if (!key) { report.indexnow = { skipped: 'site.json 에 indexNowKey 없음 (워커 data 단계가 만든다)' }; } + else { + const keyUrl = `${URLBASE}/${key}.txt`; + const kf = await head(keyUrl); + if (!kf.ok || kf.text.trim() !== key) { report.indexnow = { skipped: `키 파일 확인 실패 ${keyUrl} (${kf.status})` }; } + else { + try { + const host = new URL(URLBASE).host; + const r = await fetch('https://api.indexnow.org/indexnow', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls.slice(0, 10000) }) }); + report.indexnow = { status: r.status, submitted: Math.min(urls.length, 10000), engines: 'Bing·Naver(IndexNow 참여)' }; + } catch (e) { report.indexnow = { error: String(e.message ?? e) }; } + } + } +} else { + report.indexnow = { skipped: report.indexable ? '사이트맵 URL 없음' : '색인 허용 빌드가 아님(noindex). 샘플 단계에서는 알리지 않는다' }; +} + +// ---------- 3. Google Search Console ---------- +async function googleToken(sa) { + const now = Math.floor(Date.now() / 1000); + const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const unsigned = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`; + const sig = createSign('RSA-SHA256').update(unsigned).sign(sa.private_key, 'base64url'); + const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${unsigned}.${sig}` }); + if (!r.ok) throw new Error(`토큰 실패 ${r.status}`); + return (await r.json()).access_token; +} +const saRaw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ''; +if (!report.indexable) { + report.google = { skipped: '색인 허용 빌드가 아님' }; +} else if (!saRaw) { + report.google = { skipped: 'GOOGLE_SERVICE_ACCOUNT_JSON 없음' }; + task('gsc-owner', 'Google Search Console 속성 등록·소유 확인 (첫 1회)', '서비스 계정이 없거나 속성 소유자로 등록되지 않아 사이트맵 제출과 색인 상태 조회를 자동으로 할 수 없습니다.', `1) Search Console 에 ${URLBASE} 속성 추가 2) 소유 확인: site.json googleSiteVerification 에 메타 태그 값(content)을 넣고 재빌드 3) 서비스 계정 이메일을 속성 소유자로 추가 4) Productize .env 에 GOOGLE_SERVICE_ACCOUNT_JSON 경로`); +} else { + try { + const sa = JSON.parse(existsSync(saRaw) ? readFileSync(saRaw, 'utf8') : saRaw); + const token = await googleToken(sa); + const siteUrl = `${URLBASE}/`; + const smUrl = `${URLBASE}/sitemap.xml`; + const put = await fetch(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(smUrl)}`, { method: 'PUT', headers: { authorization: `Bearer ${token}` } }); + const g = { sitemapSubmit: put.status, inspected: [] }; + if (put.status === 403 || put.status === 404) { + task('gsc-owner', 'Google Search Console 소유자 추가', `사이트맵 제출이 ${put.status} 로 거절됐습니다. 서비스 계정(${sa.client_email})이 속성 소유자가 아닙니다.`, `Search Console → 설정 → 사용자 및 권한 → ${sa.client_email} 을 소유자로 추가`); + } else { + for (const u of urls.slice(0, 20)) { + const r = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: u, siteUrl }) }); + const j = r.ok ? await r.json() : null; + g.inspected.push({ url: u, verdict: j?.inspectionResult?.indexStatusResult?.verdict ?? `HTTP ${r.status}`, coverage: j?.inspectionResult?.indexStatusResult?.coverageState ?? '' }); + } + g.indexed = g.inspected.filter((x) => x.verdict === 'PASS').length; + } + report.google = g; + } catch (e) { report.google = { error: String(e.message ?? e) }; task('gsc-error', 'Google Search Console 연동 오류 확인', String(e.message ?? e), 'GOOGLE_SERVICE_ACCOUNT_JSON 값과 서비스 계정 권한 확인'); } +} + +// ---------- 4. 네이버 서치어드바이저 (공개 API 없음 → 사람) ---------- +if (report.indexable) { + report.naver = { via: 'IndexNow 로 URL 알림' + (report.indexnow?.status ? ` (${report.indexnow.status})` : ''), verificationMeta: Boolean(site.naverSiteVerification) }; + task('naver-register', '네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵 제출 (첫 1회)', '네이버는 사이트 등록과 사이트맵·RSS 제출에 공개 API 가 없습니다. IndexNow 로 URL 은 알렸지만 등록이 돼 있어야 수집 현황을 볼 수 있습니다.', + `1) searchadvisor.naver.com 에 ${URLBASE} 등록 2) 소유 확인: HTML 태그 방식의 content 값을 site.json naverSiteVerification 에 넣고 재빌드 3) 요청 → 사이트맵 제출 ${URLBASE}/sitemap.xml 4) 요청 → 웹 페이지 수집(하루 50건)에 홈·주요 글 URL`); + task('naver-track', '네이버 블로그 재작성 트랙 시작', '네이버 블로그·플레이스·카페는 AI 크롤러를 막고 있어 네이버 노출은 별도 트랙입니다(설계 v0.1 §8).', '허브 발행 24시간 뒤 요약 재작성본(60% 이하 분량)에 허브 링크. 병원 명의 여부는 §7 사전심의 확인 뒤'); + task('backlinks', '병원 자산에서 서포터즈로 링크·sameAs 연결', '새 도메인은 권위가 0 이라 색인이 몇 주 걸릴 수 있습니다. 병원 홈페이지·유튜브 채널 설명·강남언니·구글 비즈니스 프로필에서 연결하면 빨라집니다.', '병원 담당자에게 링크 추가 요청. 서포터즈 JSON-LD 의 sameAs 는 이미 병원 자산을 가리킨다'); +} else { + report.naver = { skipped: '색인 허용 빌드가 아님' }; + task('go-indexable', '색인 허용 전환 결정', '지금은 샘플(noindex) 상태입니다. 전환 전에 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)과 글 게시 승인(approvedAt)이 끝나야 합니다.', `결정되면 워커를 --indexable --deploy 로 재실행. 도메인이 정해졌으면 --domain <도메인> 을 함께`); +} +const isVercelDefault = /^https:\/\/[a-z0-9-]+\.vercel\.app$/.test(URLBASE); +if (isVercelDefault) { + task('domain', '정식 도메인 연결', '지금 주소는 Vercel 기본 도메인입니다. 정식 도메인(별도 도메인 vs 병원 서브도메인, 설계 v0.1 §3)이 정해져야 색인 작업이 의미가 있습니다.', '도메인 결정 → 워커 --domain <도메인> (Vercel 프로젝트에 도메인 추가) → DNS 설정 → 재실행'); +} + +// ---------- 5. 체크리스트 ---------- +mkdirSync(WORK, { recursive: true }); +const md = [`# 발행 체크리스트 · ${fact.shortName || clinic} · ${today}`, '', `라이브 ${URLBASE} · 홈 ${home.status} · robots ${robots.status} · sitemap ${sitemap.status} (URL ${urls.length}) · 색인 허용 ${report.indexable ? '예' : '아니오(noindex)'}`, '', + `## 자동으로 한 것`, `- IndexNow: ${report.indexnow?.status ? `제출 ${report.indexnow.submitted}건, 응답 ${report.indexnow.status}` : (report.indexnow?.skipped || report.indexnow?.error || '없음')}`, + `- Google Search Console: ${report.google?.sitemapSubmit ? `사이트맵 제출 ${report.google.sitemapSubmit}, 색인 확인 ${report.google.indexed ?? 0}/${report.google.inspected?.length ?? 0}` : (report.google?.skipped || report.google?.error || '없음')}`, '', + `## 사람이 할 것 (이것까지 끝나야 완료)`, ...report.humanTasks.map((t, i) => `${i + 1}. **${t.title}**\n - 왜: ${t.why}\n - 어떻게: ${t.how}`), ''].join('\n'); +writeFileSync(join(WORK, 'publish-checklist.md'), md); +writeFileSync(join(WORK, 'publish-report.json'), JSON.stringify(report, null, 2)); +console.log(md); +console.log(`__PUBLISH_JSON__${JSON.stringify({ live: report.live, indexable: report.indexable, indexnow: report.indexnow, google: report.google && { sitemapSubmit: report.google.sitemapSubmit, indexed: report.google.indexed, skipped: report.google.skipped, error: report.google.error }, humanTasks: report.humanTasks.map((t) => t.title) })}`); diff --git a/supporters/scripts/template/data/site.json b/supporters/scripts/template/data/site.json index c6e9fa2..3b32819 100644 --- a/supporters/scripts/template/data/site.json +++ b/supporters/scripts/template/data/site.json @@ -23,5 +23,9 @@ "newsOutletsExample": "", "sponsorNotice": "", "ga4MeasurementId": "", - "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다." + "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.", + "indexNowKey": "", + "googleSiteVerification": "", + "naverSiteVerification": "", + "_comment_publish": "indexNowKey 는 워커 data 단계가 자동 생성(public/.txt). googleSiteVerification·naverSiteVerification 은 각 콘솔의 HTML 태그 content 값을 사람이 넣는다(briefs//clinic.json site 로 전달 가능). 발행 단계는 scripts/publish_site.mjs." } diff --git a/supporters/src/data/site.json b/supporters/src/data/site.json index 0444c6d..e32811b 100644 --- a/supporters/src/data/site.json +++ b/supporters/src/data/site.json @@ -6,44 +6,151 @@ "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": "뷰성형외과 빌딩 외관. 출처: 공식 홈페이지 병원둘러보기" }, + "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" } + { + "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 치과 (양악·윤곽 협진)" } + { + "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": "상담 대기 라운지" } + { + "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" ], - "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": "주차장 진입 경로. 출처: 홈페이지 오시는길" } + { + "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": "재수술 상담 준비 체크리스트" } + { + "href": "/posts/visit-guide", + "title": "방문 가이드: 위치·진료시간·주차·예약", + "note": "대중교통·공항에서 오는 법까지 자세히" + }, + { + "href": "/posts/safety-system", + "title": "뷰성형외과 안전 시스템은 무엇으로 이루어져 있나요?", + "note": "상담에서 확인할 네 가지" + }, + { + "href": "/posts/revision-checklist", + "title": "가슴 재수술을 고민 중이라면 무엇을 먼저 확인해야 하나요?", + "note": "재수술 상담 준비 체크리스트" + } ], "newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.", - "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등" + "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등", + "indexNowKey": "", + "googleSiteVerification": "", + "naverSiteVerification": "" } diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index d9cba52..9379d37 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -57,6 +57,8 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array {(!INDEXABLE || noindex) && } + {(S as Record).googleSiteVerification && ).googleSiteVerification} />} + {(S as Record).naverSiteVerification && ).naverSiteVerification} />} diff --git a/templates/supporters-astro/scripts/publish_site.mjs b/templates/supporters-astro/scripts/publish_site.mjs new file mode 100644 index 0000000..8a5f5f8 --- /dev/null +++ b/templates/supporters-astro/scripts/publish_site.mjs @@ -0,0 +1,127 @@ +// 배포 후 색인 작업 (워커 publish 단계). "서포터즈 사이트 완료"는 배포가 아니라 이 단계와 사람 체크리스트까지 끝난 상태다 (haewon 결정 2026-09-11). +// node scripts/publish_site.mjs --clinic --site --url https:// [--work ] [--indexable] +// +// 자동으로 하는 것 +// 1. 라이브 확인: 홈·robots.txt·sitemap.xml 응답, X-Robots-Tag(noindex) 여부, 사이트맵 URL 수 +// 2. IndexNow(빙·네이버 공용): 색인 허용 빌드일 때만 사이트맵의 URL 전부를 api.indexnow.org 에 알린다. 키 파일은 워커 data 단계가 public/.txt 로 넣는다 +// 3. Google Search Console: GOOGLE_SERVICE_ACCOUNT_JSON(경로 또는 JSON) 이 있으면 사이트맵 제출 + 최대 20 URL 색인 상태 조회(URL Inspection API). +// 서비스 계정이 속성 소유자로 등록돼 있지 않으면 403 → 사람 체크리스트로 넘긴다 +// 사람에게 남기는 것 (/publish-checklist.md, status.humanTasks) +// 네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유 확인(첫 1회), 도메인 연결, 색인 허용 전환 전 법무·승인 확인 +// 하지 않는 것: Google Indexing API(채용·라이브 방송 전용, 일반 페이지에 쓰면 가이드라인 위반), 색인 보장 약속. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createSign } from 'node:crypto'; + +const args = process.argv.slice(2); +const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; +const flag = (k) => args.includes(`--${k}`); +const clinic = opt('clinic'); const SITE = opt('site'); const URLBASE = (opt('url') || '').replace(/\/$/, ''); +if (!clinic || !SITE || !URLBASE) { console.error('사용법: --clinic --site --url https:// [--work ] [--indexable]'); process.exit(2); } +const WORK = resolve(opt('work', join(SITE, '..'))); +const site = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'site.json'), 'utf8')); +const fact = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'factSheet.json'), 'utf8')); +const today = new Date().toISOString().slice(0, 10); +const report = { clinic, url: URLBASE, checkedAt: new Date().toISOString(), live: {}, indexable: false, indexnow: null, google: null, naver: null, humanTasks: [] }; +const task = (id, title, why, how) => report.humanTasks.push({ id, title, why, how }); + +async function head(url) { + try { const r = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'infinith-supporters-publish/1.0' } }); const text = r.ok ? await r.text() : ''; return { ok: r.ok, status: r.status, robotsTag: r.headers.get('x-robots-tag') || '', text }; } + catch (e) { return { ok: false, status: 0, robotsTag: '', text: '', error: String(e.message ?? e) }; } +} + +// ---------- 1. 라이브 확인 ---------- +const home = await head(`${URLBASE}/`); +const robots = await head(`${URLBASE}/robots.txt`); +const sitemap = await head(`${URLBASE}/sitemap.xml`); +const urls = [...(sitemap.text.matchAll(/([^<]+)<\/loc>/g))].map((m) => m[1].trim()); +const noindexHeader = /noindex/i.test(home.robotsTag); +const robotsBlocksAll = /Disallow:\s*\/\s*$/m.test(robots.text) && !/Allow:\s*\//.test(robots.text); +report.live = { home: home.status, robots: robots.status, sitemap: sitemap.status, sitemapUrls: urls.length, noindexHeader, robotsBlocksAll }; +report.indexable = flag('indexable') || (home.ok && !noindexHeader && !robotsBlocksAll); +if (!home.ok) task('live', '라이브 응답 확인', `홈이 ${home.status} 로 응답했습니다.`, `${URLBASE}/ 를 열어 배포 상태를 확인`); + +// ---------- 2. IndexNow (빙·네이버) ---------- +if (report.indexable && urls.length) { + const key = site.indexNowKey; + if (!key) { report.indexnow = { skipped: 'site.json 에 indexNowKey 없음 (워커 data 단계가 만든다)' }; } + else { + const keyUrl = `${URLBASE}/${key}.txt`; + const kf = await head(keyUrl); + if (!kf.ok || kf.text.trim() !== key) { report.indexnow = { skipped: `키 파일 확인 실패 ${keyUrl} (${kf.status})` }; } + else { + try { + const host = new URL(URLBASE).host; + const r = await fetch('https://api.indexnow.org/indexnow', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls.slice(0, 10000) }) }); + report.indexnow = { status: r.status, submitted: Math.min(urls.length, 10000), engines: 'Bing·Naver(IndexNow 참여)' }; + } catch (e) { report.indexnow = { error: String(e.message ?? e) }; } + } + } +} else { + report.indexnow = { skipped: report.indexable ? '사이트맵 URL 없음' : '색인 허용 빌드가 아님(noindex). 샘플 단계에서는 알리지 않는다' }; +} + +// ---------- 3. Google Search Console ---------- +async function googleToken(sa) { + const now = Math.floor(Date.now() / 1000); + const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const unsigned = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`; + const sig = createSign('RSA-SHA256').update(unsigned).sign(sa.private_key, 'base64url'); + const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${unsigned}.${sig}` }); + if (!r.ok) throw new Error(`토큰 실패 ${r.status}`); + return (await r.json()).access_token; +} +const saRaw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ''; +if (!report.indexable) { + report.google = { skipped: '색인 허용 빌드가 아님' }; +} else if (!saRaw) { + report.google = { skipped: 'GOOGLE_SERVICE_ACCOUNT_JSON 없음' }; + task('gsc-owner', 'Google Search Console 속성 등록·소유 확인 (첫 1회)', '서비스 계정이 없거나 속성 소유자로 등록되지 않아 사이트맵 제출과 색인 상태 조회를 자동으로 할 수 없습니다.', `1) Search Console 에 ${URLBASE} 속성 추가 2) 소유 확인: site.json googleSiteVerification 에 메타 태그 값(content)을 넣고 재빌드 3) 서비스 계정 이메일을 속성 소유자로 추가 4) Productize .env 에 GOOGLE_SERVICE_ACCOUNT_JSON 경로`); +} else { + try { + const sa = JSON.parse(existsSync(saRaw) ? readFileSync(saRaw, 'utf8') : saRaw); + const token = await googleToken(sa); + const siteUrl = `${URLBASE}/`; + const smUrl = `${URLBASE}/sitemap.xml`; + const put = await fetch(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(smUrl)}`, { method: 'PUT', headers: { authorization: `Bearer ${token}` } }); + const g = { sitemapSubmit: put.status, inspected: [] }; + if (put.status === 403 || put.status === 404) { + task('gsc-owner', 'Google Search Console 소유자 추가', `사이트맵 제출이 ${put.status} 로 거절됐습니다. 서비스 계정(${sa.client_email})이 속성 소유자가 아닙니다.`, `Search Console → 설정 → 사용자 및 권한 → ${sa.client_email} 을 소유자로 추가`); + } else { + for (const u of urls.slice(0, 20)) { + const r = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: u, siteUrl }) }); + const j = r.ok ? await r.json() : null; + g.inspected.push({ url: u, verdict: j?.inspectionResult?.indexStatusResult?.verdict ?? `HTTP ${r.status}`, coverage: j?.inspectionResult?.indexStatusResult?.coverageState ?? '' }); + } + g.indexed = g.inspected.filter((x) => x.verdict === 'PASS').length; + } + report.google = g; + } catch (e) { report.google = { error: String(e.message ?? e) }; task('gsc-error', 'Google Search Console 연동 오류 확인', String(e.message ?? e), 'GOOGLE_SERVICE_ACCOUNT_JSON 값과 서비스 계정 권한 확인'); } +} + +// ---------- 4. 네이버 서치어드바이저 (공개 API 없음 → 사람) ---------- +if (report.indexable) { + report.naver = { via: 'IndexNow 로 URL 알림' + (report.indexnow?.status ? ` (${report.indexnow.status})` : ''), verificationMeta: Boolean(site.naverSiteVerification) }; + task('naver-register', '네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵 제출 (첫 1회)', '네이버는 사이트 등록과 사이트맵·RSS 제출에 공개 API 가 없습니다. IndexNow 로 URL 은 알렸지만 등록이 돼 있어야 수집 현황을 볼 수 있습니다.', + `1) searchadvisor.naver.com 에 ${URLBASE} 등록 2) 소유 확인: HTML 태그 방식의 content 값을 site.json naverSiteVerification 에 넣고 재빌드 3) 요청 → 사이트맵 제출 ${URLBASE}/sitemap.xml 4) 요청 → 웹 페이지 수집(하루 50건)에 홈·주요 글 URL`); + task('naver-track', '네이버 블로그 재작성 트랙 시작', '네이버 블로그·플레이스·카페는 AI 크롤러를 막고 있어 네이버 노출은 별도 트랙입니다(설계 v0.1 §8).', '허브 발행 24시간 뒤 요약 재작성본(60% 이하 분량)에 허브 링크. 병원 명의 여부는 §7 사전심의 확인 뒤'); + task('backlinks', '병원 자산에서 서포터즈로 링크·sameAs 연결', '새 도메인은 권위가 0 이라 색인이 몇 주 걸릴 수 있습니다. 병원 홈페이지·유튜브 채널 설명·강남언니·구글 비즈니스 프로필에서 연결하면 빨라집니다.', '병원 담당자에게 링크 추가 요청. 서포터즈 JSON-LD 의 sameAs 는 이미 병원 자산을 가리킨다'); +} else { + report.naver = { skipped: '색인 허용 빌드가 아님' }; + task('go-indexable', '색인 허용 전환 결정', '지금은 샘플(noindex) 상태입니다. 전환 전에 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)과 글 게시 승인(approvedAt)이 끝나야 합니다.', `결정되면 워커를 --indexable --deploy 로 재실행. 도메인이 정해졌으면 --domain <도메인> 을 함께`); +} +const isVercelDefault = /^https:\/\/[a-z0-9-]+\.vercel\.app$/.test(URLBASE); +if (isVercelDefault) { + task('domain', '정식 도메인 연결', '지금 주소는 Vercel 기본 도메인입니다. 정식 도메인(별도 도메인 vs 병원 서브도메인, 설계 v0.1 §3)이 정해져야 색인 작업이 의미가 있습니다.', '도메인 결정 → 워커 --domain <도메인> (Vercel 프로젝트에 도메인 추가) → DNS 설정 → 재실행'); +} + +// ---------- 5. 체크리스트 ---------- +mkdirSync(WORK, { recursive: true }); +const md = [`# 발행 체크리스트 · ${fact.shortName || clinic} · ${today}`, '', `라이브 ${URLBASE} · 홈 ${home.status} · robots ${robots.status} · sitemap ${sitemap.status} (URL ${urls.length}) · 색인 허용 ${report.indexable ? '예' : '아니오(noindex)'}`, '', + `## 자동으로 한 것`, `- IndexNow: ${report.indexnow?.status ? `제출 ${report.indexnow.submitted}건, 응답 ${report.indexnow.status}` : (report.indexnow?.skipped || report.indexnow?.error || '없음')}`, + `- Google Search Console: ${report.google?.sitemapSubmit ? `사이트맵 제출 ${report.google.sitemapSubmit}, 색인 확인 ${report.google.indexed ?? 0}/${report.google.inspected?.length ?? 0}` : (report.google?.skipped || report.google?.error || '없음')}`, '', + `## 사람이 할 것 (이것까지 끝나야 완료)`, ...report.humanTasks.map((t, i) => `${i + 1}. **${t.title}**\n - 왜: ${t.why}\n - 어떻게: ${t.how}`), ''].join('\n'); +writeFileSync(join(WORK, 'publish-checklist.md'), md); +writeFileSync(join(WORK, 'publish-report.json'), JSON.stringify(report, null, 2)); +console.log(md); +console.log(`__PUBLISH_JSON__${JSON.stringify({ live: report.live, indexable: report.indexable, indexnow: report.indexnow, google: report.google && { sitemapSubmit: report.google.sitemapSubmit, indexed: report.google.indexed, skipped: report.google.skipped, error: report.google.error }, humanTasks: report.humanTasks.map((t) => t.title) })}`); diff --git a/templates/supporters-astro/src/data/site.json b/templates/supporters-astro/src/data/site.json index c6e9fa2..3b32819 100644 --- a/templates/supporters-astro/src/data/site.json +++ b/templates/supporters-astro/src/data/site.json @@ -23,5 +23,9 @@ "newsOutletsExample": "", "sponsorNotice": "", "ga4MeasurementId": "", - "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다." + "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.", + "indexNowKey": "", + "googleSiteVerification": "", + "naverSiteVerification": "", + "_comment_publish": "indexNowKey 는 워커 data 단계가 자동 생성(public/.txt). googleSiteVerification·naverSiteVerification 은 각 콘솔의 HTML 태그 content 값을 사람이 넣는다(briefs//clinic.json site 로 전달 가능). 발행 단계는 scripts/publish_site.mjs." } diff --git a/templates/supporters-astro/src/layouts/Base.astro b/templates/supporters-astro/src/layouts/Base.astro index d9cba52..9379d37 100644 --- a/templates/supporters-astro/src/layouts/Base.astro +++ b/templates/supporters-astro/src/layouts/Base.astro @@ -57,6 +57,8 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array {(!INDEXABLE || noindex) && } + {(S as Record).googleSiteVerification && ).googleSiteVerification} />} + {(S as Record).naverSiteVerification && ).naverSiteVerification} />} diff --git a/workers/supporters-build/run.mjs b/workers/supporters-build/run.mjs index b424b04..d1b69fe 100644 --- a/workers/supporters-build/run.mjs +++ b/workers/supporters-build/run.mjs @@ -26,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url'); if (!clinic || !url) { console.error('사용법: --clinic --url '); process.exit(2); } const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic))); const today = new Date().toISOString().slice(0, 10); -const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done']; +const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'publish', 'done']; const startFrom = opt('from', 'evidence'); (function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })(); @@ -112,6 +112,13 @@ await phase('data', () => { if (existsSync(join(KEEP, 'posts'))) cpSync(join(KEEP, 'posts'), POSTS, { recursive: true }); if (existsSync(join(KEEP, 'videos.json'))) cpSync(join(KEEP, 'videos.json'), join(DATA, 'videos.json')); for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); } + // 색인 허용 빌드(--indexable): noindex 헤더 제거 + PUBLIC_INDEXABLE=true(승인된 글만 발행). 정식 도메인(--domain)이 있으면 SITE_URL 을 그 주소로. + const liveUrl = opt('domain') ? `https://${opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '')}` : `https://supporters-${clinic}.vercel.app`; + { const vp = join(SITE, 'vercel.json'); const vj = readJson(vp); vj.build = vj.build ?? {}; vj.build.env = { ...(vj.build.env ?? {}), SITE_URL: liveUrl, PUBLIC_INDEXABLE: flag('indexable') ? 'true' : 'false' }; + if (flag('indexable')) vj.headers = (vj.headers ?? []).filter((h) => !(h.headers ?? []).some((x) => x.key === 'X-Robots-Tag')); writeJson(vp, vj); } + // IndexNow 키: 사이트마다 하나. public/.txt 로 두면 빙·네이버가 소유를 확인한다. 검색엔진 소유 확인 메타 값은 briefs//clinic.json 의 site 로 들어온다(inputs 단계). + { const sp = join(DATA, 'site.json'); const sj = readJson(sp); if (!sj.indexNowKey) { sj.indexNowKey = [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); writeJson(sp, sj); } + mkdirSync(join(SITE, 'public'), { recursive: true }); writeFileSync(join(SITE, 'public', `${sj.indexNowKey}.txt`), sj.indexNowKey); } cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt')); // 팩트 시트: 후보 중 첫 값. 사람이 대조하기 전까지 "확인 대기"가 붙는 필드가 많다(v2 §4-5). @@ -429,12 +436,30 @@ await phase('deploy', () => { const project = `supporters-${clinic}`; const link = run('vercel', ['link', '--yes', '--project', project], { cwd: SITE }); if (!link.ok) throw new Error(`vercel link 실패\n${tail(link.out)}`); - const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: `https://${project}.vercel.app` } }); + const domain = opt('domain') ? opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '') : ''; + if (domain) { const d = run('vercel', ['domains', 'add', domain, project, '--yes'], { cwd: SITE }); status.report = { ...(status.report ?? {}), domain: d.ok ? `연결 요청됨 ${domain}` : `도메인 추가 실패(DNS·소유 확인 필요)\n${tail(d.out, 3)}` }; } + const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: domain ? `https://${domain}` : `https://${project}.vercel.app` } }); const m = dep.out.match(/https:\/\/[^\s]+\.vercel\.app/g); if (!dep.ok || !m) throw new Error(`vercel deploy 실패\n${tail(dep.out)}`); status.previewUrl = `https://${project}.vercel.app`; - return status.previewUrl; + status.liveUrl = domain ? `https://${domain}` : status.previewUrl; + return status.liveUrl; }); -await phase('done', async () => { await setStatus({ status: 'preview' }); return `status.json → ${statusPath}`; }); -console.log(`\n완료: ${status.posts.length}편 · ${status.previewUrl ?? '미배포'} · ${WORK}`); +// 8. 발행(색인) 단계: 배포가 끝이 아니다. 라이브 확인 → IndexNow(빙·네이버) → Google Search Console(서비스 계정 있을 때) → 사람 체크리스트(네이버 등록·GSC 소유 확인·도메인·법무). haewon 결정 2026-09-11. +await phase('publish', () => { + if (!status.previewUrl) return '건너뜀 (미배포)'; + const argv = ['scripts/publish_site.mjs', '--clinic', clinic, '--site', SITE, '--url', status.liveUrl || status.previewUrl, '--work', WORK]; + if (flag('indexable')) argv.push('--indexable'); + const r = run('node', argv); + const m = r.out.match(/__PUBLISH_JSON__(.+)$/m); + const pub = m ? JSON.parse(m[1]) : null; + if (!r.ok || !pub) return { warn: true, summary: `발행 단계 실패 (체크리스트 없이 종료)\n${tail(r.out, 4)}` }; + status.report = { ...(status.report ?? {}), publish: pub }; + status.humanTasks = pub.humanTasks; + const n = pub.humanTasks.length; + return `색인 허용 ${pub.indexable ? '예' : '아니오'} · IndexNow ${pub.indexnow?.status ?? pub.indexnow?.skipped ?? '-'} · GSC ${pub.google?.sitemapSubmit ?? pub.google?.skipped ?? '-'} · 사람이 할 일 ${n}건 → ${join(WORK, 'publish-checklist.md')}`; +}); + +await phase('done', async () => { await setStatus({ status: flag('indexable') ? (status.humanTasks?.length ? 'published_pending_tasks' : 'published') : 'preview' }); return `status.json → ${statusPath}`; }); +console.log(`\n완료: ${status.posts.length}편 · ${status.liveUrl ?? status.previewUrl ?? '미배포'} · ${WORK}${status.humanTasks?.length ? `\n사람이 할 일 ${status.humanTasks.length}건 (publish-checklist.md):\n - ${status.humanTasks.join('\n - ')}` : ''}`);