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