feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)

- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16)
- 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A]
- default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs
- 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시)
- v2 §11-6·§11-7 기록, E2E 리포트

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-07 17:07:06 +09:00
parent 64a3bce9a3
commit 7e515b70ae
56 changed files with 2530 additions and 41 deletions

View File

@ -237,8 +237,8 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
|---|---|---|
| 3 근거 수집기 (`evidence/<clinic>/`) | **완료** (커밋 c92ecc8, §11-4) | 없음 |
| 4 글 생성기 | **완료** (§11-5) | 편당 약 $0.09 실측. 단가 상한은 --max-cost-usd 로 걸 수 있고 값은 미정 |
| 5 승인 UI (§6 입력 폼) | 미착수 | 폼은 INFINITH 랜딩으로 결정(§12-1). 법적 주체는 §12-2 제안 확인 |
| 6 워커·배포·랜딩 연결 | 미착수 | §12-3 추천안 확인, `discovery_leads` 마이그레이션 미적용 |
| 5 승인 UI (§6 입력 폼) | **완료** (§11-6) | 마이그레이션 미적용(supabase db push). 상단 고지·개설자 승인은 §12-2 제안 확인 후 켠다 |
| 6 워커·배포·랜딩 연결 | **워커·랜딩 완료, 배포 미실행** (§11-7) | 워커 실행 위치(§12-3 Trigger.dev)와 첫 Vercel 프로젝트 생성은 haewon 확인 후 |
| 7 두 번째 병원 회귀 | 미착수 | §9-4 병원 선정 |
| 「채널 인기 전체」 탭 | 경고만 | §9-1. 제거로 결정되면 `check.mjs`의 `level: R.W`를 지우고 videos.astro 탭을 뺀다 |
@ -263,6 +263,21 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
- 한계: 본문이 이미지 글자인 페이지(코 메인·가슴재수술·진료분야)는 근거 0자로 들어가 pending 에 쌓인다. 자막 없는 롱폼은 인용하지 않으므로 그 글은 정답지보다 얇다(보형물 표면 글 650~900자). 자막 파이프라인(v1 §6)이 붙으면 `evidence.transcripts` 로 넣는다.
- 미결: 단가 상한값(§9-5). 실측 편당 $0.05~0.19, 첫 배치 10편이면 약 $1.
### 11-6. 승인 UI · 입력 반영 (§7-5, §12-2 코드 반영) · 2026-09-07 세션 3
- 테이블: `supabase/migrations/20260907_supporter_builds.sql` — `supporter_builds`(빌드 실행 기록·단계·글 목록·미리보기 URL, 워커가 service_role 로 갱신) · `supporter_inputs`(§6 9항목 + 글별 post_review/post_approval, 익명 insert/select). **미적용** (discovery_leads 와 함께 `supabase db push`).
- 폼: `src/components/discovery/ClinicInputsPanel.tsx` → `/discovery/:id` 5절과 6절 사이. 빌드 상태·미리보기 링크, 9항목 입력(입력자 기록), 의료진 ID 표, 글별 검토(검토자 ID·검토일)와 게시 승인(개설자 이름·직함·승인일). 검토 전 승인 버튼은 비활성. tsc 통과.
- 반영: `supporters/scripts/apply_inputs.mjs --inputs file | --from-supabase`. 같은 (key, post_id) 최신 행만. author/editor → authors·corrections, sponsorship → site.sponsorNotice, specialty_doctors → physicians.specialty, domain → site, 자유 답변 4종 → `src/data/clinicAnswers.json`(생성기 근거 [A], `--site` 옵션으로 워커 작업 폴더에서도 동작), post_review → reviewer·reviewStatus=reviewed·reviewedAt·history, post_approval → approvedBy·approvedAt(검토 전이면 보류). 샘플 `supporters/briefs/viewclinic/inputs.sample.json` dry-run 9건 반영 확인.
- §12-2 코드 반영: (1) `DisclosureTop.astro` + 게이트 `DISCLOSURE_TOP_MISSING`(site.sponsorNotice 비면 warn, 채우면 error). 지금은 비어 있어 룩앤필 변화 없음. (2) frontmatter `approvedBy/approvedAt`, `lib.isPublishable`: PUBLIC_INDEXABLE=true 빌드는 승인 글만 낸다(글·목록·사이트맵·작성자 페이지). 검증: 색인 허용 빌드 → 승인 0편이라 글 0개, 게이트 오류 0. 게이트 `APPROVED_WITHOUT_BY/DATE`·`APPROVED_BEFORE_REVIEW`·`UNAPPROVED_IN_INDEXABLE_BUILD`. (3) `PRESS_VOICE` 경고를 rules.mjs 로. 규제 주장 검사에서 「게시 승인」은 제외.
- 랜딩 카피: SampleReport 의 "SAMPLE REPORT · 뷰성형외과 실측" → "SAMPLE REPORT · 실제 사례"(haewon 지시). 두 워크트리(이 브랜치, feature/discovery-url-cta)에 적용, **프로덕션 미배포**.
### 11-7. 워커 (§7-6) · 2026-09-07 세션 3
- `workers/supporters-build/run.mjs --clinic <id> --url <홈> [--inputs|--from-supabase] [--brief] [--max-posts] [--max-cost-usd] [--deploy] [--build-id]`. 실행 환경 독립(로컬·GitHub Actions·Trigger.dev 태스크 어디서든 같은 명령). 단계 evidence → data → inputs → briefs → generate → build → deploy → done, 단계마다 `<work>/status.json` + (build-id 있으면) supporter_builds PATCH.
- data 단계: 템플릿 복제 + `__CLINIC_ID__` 치환 + facts.draft 첫 후보로 팩트 시트(상호·전화·주소·사업자번호·대표, checkedAt/nextCheck) + doctors.json 으로 physicians(이름·직함·URL, 약력은 빈 배열) + 작성자 "확인 대기".
- briefs 단계: `supporters/scripts/default_briefs.mjs` 가 근거 유형(about·direction·safety·precautions·aftercare·checkup)이 실제로 있는 문항만 골라 첫 배치 최대 6편(A 병원 소개, B 방문, E 안전, D 주의사항, C 상담 질문, D 애프터케어). 비교 수치·가격·전후·경험담은 avoid 에 고정.
- **뷰성형외과 E2E 실측**: URL 하나 → 223페이지 수집 → 의료진 27명 → brief 5편(병원 소개는 about 페이지 본문 0자라 제외) → 5/5편 게이트 통과(수정 호출 0, $0.29, 편당 15초) → npm ci·astro build·check.mjs 오류 0(경고 2) → dist 생성. 사람 개입 0. 배포는 `--deploy` 를 주지 않아 건너뜀. 리포트 `docs/reports/viewclinic/04_supporters/Viewclinic_Supporters_Worker_E2E_2026-09-07.md`, brief `generated_samples/default_briefs_viewclinic.json`.
- deploy 단계(미실행): `vercel link --yes --project supporters-<id>` + `vercel deploy --prod --yes`. 첫 실행은 Vercel 프로젝트를 새로 만드는 외부 행위라 haewon 확인 후. 트리거(discovery_leads insert → Database Webhook → 워커)와 Trigger.dev 래핑은 §12-3 확인 후.
- 남은 것: discover-channels(유튜브·인스타 핸들)·유튜브 집계·뉴스룸(build_news.py)·감사 재실행(Phase 9)은 워커에 아직 안 붙었다. 이 세 개는 뷰성형외과 샘플에서 사람이 돌린 스크립트가 있으므로 다음 세션에 단계로 추가한다.
---
## 12. haewon 결정 반영과 제안 (2026-09-07 세션 2)
@ -275,7 +290,7 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
| 9-5 생성 모델 | **GPT-4 계열** (`OPENAI_API_KEY` 있음). 단가 상한은 첫 배치 실측 후 정함 |
| 9-3 법적 주체 | 리서치 후 제안 → §12-2 |
| 9-6 워커·배포 | 추천 → §12-3 |
| 9-1 탭 제거, 9-4 두 번째 병원 | 미결 |
| 9-1 탭 제거, 9-4 두 번째 병원, 9-5 단가 상한값, 첫 Vercel 프로젝트 생성 | 미결 |
### 12-2. 검토 승인의 법적 책임 주체 · 제안
근거 조문을 원문으로 확인한 결과다(법률 자문이 아니며, 계약 전 변호사 확인을 권한다).

View File

@ -0,0 +1,60 @@
# 글 생성 리포트 · viewclinic · gpt-4.1 (대조 gpt-4.1) · 2026-09-07
통과 5 · 근거 플래그 남음 0 · 게이트 실패 0 · 오류 0 / 5 · 수정 호출 0편 · 총 $0.293 · 편당 $0.059 · 편당 15.34s
가격 기준 USD/1M tokens 입력 2 · 출력 8 (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): 없음
최종 경고 분포: NUMERIC_NO_QUALIFIER×2
최종 오류 분포: 없음
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| visit-guide | B | ok | 0 | 0 | 0 | 824 | 2 | 8 | 3 | 2 | 11239/1462 | 0.034 | 12.8 |
| safety-system | E | ok | 0 | 0 | 0 | 1406 | 1 | 7 | 7 | 2 | 20919/1996 | 0.058 | 15.6 |
| precautions-guide | D | ok | 0 | 0 | 0 | 1023 | 2 | 8 | 5 | 2 | 26368/2261 | 0.071 | 16.3 |
| consult-questions | C | ok | 0 | 0 | 0 | 914 | 1 | 8 | 8 | 2 | 28580/1566 | 0.070 | 17.7 |
| aftercare-program | D | ok | 0 | 0 | 2 | 1035 | 1 | 7 | 3 | 2 | 22719/1823 | 0.060 | 14.3 |
## 근거 대조 플래그 (사람이 확인할 것)
(없음)
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
### visit-guide
- 공휴일 및 일요일 진료 여부
- 카카오톡 또는 기타 온라인 예약 경로
- 주차장 만차 시 대체 주차 안내
### safety-system
- CCTV 영상의 실제 보관 기간 및 열람 절차
- 마취통증의학과 전문의 상주 시간 및 직접 시술 여부
- 응급상황 대응 시 담당자 및 실제 프로토콜
- 수술 전/후 필수 검진 항목과 정기 검진 주기
- 각 수술별로 실제 사용되는 의료장비 목록
- 애프터케어 프로그램의 구체적 내용과 적용 대상
- 집도의의 실제 참여 범위(상담~수술~경과)
### precautions-guide
- 수술별(예: 눈, 코, 가슴 등) 세부 회복 일정의 최신 업데이트
- 개인 건강 상태에 따른 약 복용 및 중단 시점의 예외
- 특정 수술(예: 재수술, 복합수술)에서의 주의사항 변동
- 수술 후 일상 복귀 가능 시점의 평균 및 범위
- 애프터케어 프로그램 실제 적용 사례 및 상세 일정
### consult-questions
- 수술별 집도의 직접 수술 여부의 실제 적용 사례
- 마취통증의학과 전문의 상주 시간 및 수술별 배정 기준
- 애프터케어 프로그램의 실제 이용 가능 횟수와 비용
- 정밀검진 항목별 비용 및 보험 적용 여부
- 흉터 관리 서비스의 구체적 횟수와 추가 비용
- 응급장비 점검 주기 및 실제 사용 사례
- 호텔 연계 서비스의 구체적 제휴 호텔 목록
- 외국어 통역 서비스의 예약 방법 및 이용 시간
### aftercare-program
- 관리 프로그램의 비용 포함 여부
- 부위별 무제한 관리의 실제 적용 범위와 횟수
- 홈케어 제품 제공 조건 및 추가 비용 여부

View File

@ -0,0 +1,171 @@
{
"clinic": "viewclinic",
"generatedAt": "2026-09-07",
"note": "근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).",
"posts": [
{
"id": "visit-guide",
"category": "B",
"categoryLabel": "방문·예약",
"qbIds": [
"B-01",
"B-03",
"B-05"
],
"title": "뷰성형외과 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?",
"intent": "오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길 페이지만 근거로. 확인되지 않은 시간은 \"전화 확인\".",
"avoid": [
"전후 사진",
"가격 금액",
"다른 병원·업계 평균 비교",
"효과 보장",
"환자 경험담"
],
"evidence": {
"pages": [
"https://www.viewclinic.com/viewis/direction3/",
"https://www.viewclinic.com/viewis/direction/",
"https://www.viewclinic.com/counsel/online-consultation/"
],
"shorts": [],
"news": [],
"regulation": [],
"platform": false
},
"videos": [],
"gallery": []
},
{
"id": "safety-system",
"category": "E",
"categoryLabel": "안전·마취",
"qbIds": [
"E-01",
"E-02",
"E-04"
],
"title": "뷰성형외과의 안전 시스템은 무엇으로 이루어져 있나요?",
"intent": "안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.",
"avoid": [
"전후 사진",
"가격 금액",
"다른 병원·업계 평균 비교",
"효과 보장",
"환자 경험담"
],
"reviewerCandidate": "dr-songgyuyeong",
"evidence": {
"pages": [
"https://www.viewclinic.com/system/safe-surgery/",
"https://www.viewclinic.com/breast-screening/breast-examination/",
"https://www.viewclinic.com/system/examination-center/",
"https://www.viewclinic.com/commonbottom-examination/"
],
"shorts": [],
"news": [],
"regulation": [],
"platform": false
},
"videos": [],
"gallery": []
},
{
"id": "precautions-guide",
"category": "D",
"categoryLabel": "시술 정보",
"qbIds": [
"D-06",
"D-09"
],
"title": "수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?",
"intent": "수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.",
"avoid": [
"전후 사진",
"가격 금액",
"다른 병원·업계 평균 비교",
"효과 보장",
"환자 경험담"
],
"reviewerCandidate": "dr-choisoonwoo",
"evidence": {
"pages": [
"https://www.viewclinic.com/system/precautions/",
"https://www.viewclinic.com/system/after-care/"
],
"shorts": [],
"news": [],
"regulation": [],
"platform": false
},
"videos": [],
"gallery": []
},
{
"id": "consult-questions",
"category": "C",
"categoryLabel": "선택 기준",
"qbIds": [
"C-02",
"E-08"
],
"title": "상담에서 꼭 물어봐야 할 질문은 무엇인가요?",
"intent": "안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개(\"- [ ]\" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.",
"avoid": [
"전후 사진",
"가격 금액",
"다른 병원·업계 평균 비교",
"효과 보장",
"환자 경험담"
],
"reviewerCandidate": "dr-choisoonwoo",
"evidence": {
"pages": [
"https://www.viewclinic.com/system/safe-surgery/",
"https://www.viewclinic.com/system/after-care/",
"https://www.viewclinic.com/breast-screening/breast-examination/",
"https://www.viewclinic.com/system/examination-center/",
"https://www.viewclinic.com/commonbottom-examination/"
],
"shorts": [],
"news": [],
"regulation": [],
"platform": false
},
"videos": [],
"gallery": []
},
{
"id": "aftercare-program",
"category": "D",
"categoryLabel": "시술 정보",
"qbIds": [
"D-08",
"E-06"
],
"title": "수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?",
"intent": "애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 \"병원 확인 대기\".",
"avoid": [
"전후 사진",
"가격 금액",
"다른 병원·업계 평균 비교",
"효과 보장",
"환자 경험담"
],
"reviewerCandidate": "dr-choisoonwoo",
"evidence": {
"pages": [
"https://www.viewclinic.com/system/after-care/",
"https://www.viewclinic.com/breast-screening/breast-examination/",
"https://www.viewclinic.com/system/examination-center/",
"https://www.viewclinic.com/commonbottom-examination/"
],
"shorts": [],
"news": [],
"regulation": [],
"platform": false
},
"videos": [],
"gallery": []
}
]
}

View File

@ -0,0 +1,205 @@
/**
* 병원 확인 항목 패널 (서포터즈 자동 빌드 v2 §6 · §7-5 · §12-3)
* - /discovery/:id 리포트 안에서, 자동 빌드 미리보기 상태와 병원이 입력해야 하는 값을 한 곳에 둔다.
* - 읽기: supporter_builds(최신 1건) · supporter_inputs(같은 clinic_id, 최신 행이 유효값)
* - 쓰기: supporter_inputs insert (anon RLS). 저장 후 워커가 apply_inputs → 재빌드한다. 여기서는 사이트를 직접 고치지 않는다.
* - 값이 없으면 사이트는 "확인 대기"로 발행된다. 이 폼은 그 자리를 채우는 곳이다.
* - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다.
*/
import { useEffect, useMemo, useState } from 'react';
import { supabase } from '../../lib/supabase';
type Build = {
id: string;
status: string;
phase: string | null;
preview_url: string | null;
posts: Array<{ id: string; title: string; category: string; status?: string; reviewStatus?: string; pending?: string[] }>;
report: { physicians?: Array<{ id: string; name: string; title: string }>; generator?: Record<string, unknown>; totalCostUsd?: number } | null;
updated_at: string;
};
type InputRow = { key: string; post_id: string | null; value: Record<string, unknown>; input_by: string | null; created_at: string };
const STATUS_LABEL: Record<string, string> = {
queued: '대기', running: '빌드 중', gate_failed: '발행 게이트 실패', failed: '실패', preview: '미리보기 준비됨', approved: '승인 완료', published: '색인 허용 발행',
};
// v2 §6 입력 항목. key 는 supporter_inputs.key 와 apply_inputs.mjs 가 같은 값을 쓴다.
const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{ name: string; label: string; type?: 'text' | 'textarea' | 'date' }> }> = [
{ key: 'author', title: '1. 작성자 실명·직함·공개 프로필', hint: '글 상단 "작성"과 Person 스키마에 들어갑니다.', inputs: [{ name: 'name', label: '이름' }, { name: 'role', label: '직함 (예: 편집 책임)' }, { name: 'sameAs', label: '공개 프로필 URL (인터뷰·기사, 쉼표 구분)' }, { name: 'email', label: '정정 요청 이메일' }] },
{ key: 'editor', title: '편집 책임자·정정 경로', hint: '정정 기록 페이지와 푸터에 표시됩니다.', inputs: [{ name: 'name', label: '편집 책임자 이름' }, { name: 'email', label: '정정 요청 이메일' }] },
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },
{ key: 'domain', title: '9. 정식 도메인·색인 허용 시점·홈페이지 링크 위치', hint: '도메인이 정해지기 전에는 supporters-<id>.vercel.app 미리보기(noindex)로 둡니다.', inputs: [{ name: 'domain', label: '도메인' }, { name: 'indexableFrom', label: '색인 허용 시점', type: 'date' }, { name: 'homepageLinkPosition', label: '홈페이지에서의 링크 위치' }] },
];
export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
const [build, setBuild] = useState<Build | null>(null);
const [rows, setRows] = useState<InputRow[]>([]);
const [inputBy, setInputBy] = useState('');
const [form, setForm] = useState<Record<string, Record<string, string>>>({});
const [postForm, setPostForm] = useState<Record<string, { reviewer: string; reviewedAt: string; approvedBy: string; approvedAt: string }>>({});
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
const [b, i] = await Promise.all([
supabase.from('supporter_builds').select('id,status,phase,preview_url,posts,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId).order('created_at', { ascending: true }),
]);
if (!alive) return;
if (b.error || i.error) { setLoadError((b.error ?? i.error)?.message ?? '불러오기 실패'); return; }
setBuild((b.data?.[0] as Build | undefined) ?? null);
setRows((i.data as InputRow[]) ?? []);
})();
return () => { alive = false; };
}, [clinicId]);
// 최신 행이 유효값
const latest = useMemo(() => {
const m = new Map<string, InputRow>();
for (const r of rows) m.set(`${r.key}|${r.post_id ?? ''}`, r);
return m;
}, [rows]);
const current = (key: string, postId = '') => latest.get(`${key}|${postId}`);
async function save(key: string, value: Record<string, unknown>, postId?: string) {
setBusy(`${key}|${postId ?? ''}`); setMsg(null);
const { error } = await supabase.from('supporter_inputs').insert({ clinic_id: clinicId, key, post_id: postId ?? null, value, input_by: inputBy || null });
setBusy(null);
if (error) { setMsg(`저장 실패: ${error.message}`); return; }
setRows((prev) => [...prev, { key, post_id: postId ?? null, value, input_by: inputBy || null, created_at: new Date().toISOString() }]);
setMsg('저장했습니다. 다음 빌드에서 반영됩니다.');
}
const physicians = build?.report?.physicians ?? [];
const posts = build?.posts ?? [];
return (
<section className="bg-[#F4F6FB] px-6 py-20" id="clinic-inputs">
<div className="max-w-7xl mx-auto">
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">SUPPORTERS BUILD · 병원 확인 항목</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">서포터즈 사이트 미리보기와 병원이 확인해 주실 것</h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-10 break-keep">
병원 홈페이지 공개 자료만으로 만든 초안입니다. 아래 항목은 자료에 없어 병원만 답할 수 있는 값이며, 입력 전까지는 사이트에 "확인 대기"로 표시됩니다. 의학 검토와 게시 승인은 병원이 확정합니다.
</p>
{/* 빌드 상태 */}
<div className="bg-white border border-slate-200 rounded-2xl p-6 md:p-8 shadow-[0_8px_32px_rgba(31,38,135,0.07)] mb-8">
{loadError && <p className="text-sm text-[#B84C5F]">불러오기 실패: {loadError}</p>}
{!loadError && !build && <p className="text-slate-500">아직 자동 빌드 기록이 없습니다. URL 신청이 접수되면 여기에 진행 상태가 표시됩니다.</p>}
{build && (
<div className="grid md:grid-cols-[1fr_auto] gap-6 items-start">
<div>
<p className="text-sm text-slate-500 mb-1">상태</p>
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="text-sm font-medium text-slate-500 ml-2">단계 {build.phase}</span> : null}</p>
<p className="text-sm text-slate-400 mt-1">갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
</div>
{build.preview_url && (
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">미리보기 열기 (noindex)</a>
)}
</div>
)}
</div>
<div className="grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6 items-start">
{/* 9 항목 폼 */}
<div className="flex flex-col gap-5">
<label className="bg-white border border-slate-200 rounded-2xl p-5 flex flex-col gap-2">
<span className="text-sm font-semibold text-slate-600">입력자 (이름·직함)</span>
<input value={inputBy} onChange={(e) => setInputBy(e.target.value)} placeholder="예: 홍길동 원장 / 김담당 실장" className="border border-slate-200 rounded-lg px-3 py-2 text-base" />
</label>
{FIELDS.map((f) => {
const cur = current(f.key)?.value as Record<string, unknown> | undefined;
const draft = form[f.key] ?? {};
return (
<div key={f.key} className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<div className="flex items-start justify-between gap-4 mb-1">
<h3 className="font-bold text-lg text-primary-900 break-keep">{f.title}</h3>
<span className={`shrink-0 text-xs font-bold px-2.5 py-1 rounded-full ${cur ? 'bg-[#E9F7EF] text-[#2E7D4F]' : 'bg-[#FFF4E5] text-[#B26A00]'}`}>{cur ? `입력됨 ${String(current(f.key)?.created_at ?? '').slice(0, 10)}` : '확인 대기'}</span>
</div>
<p className="text-sm text-slate-500 mb-4 break-keep">{f.hint}</p>
<div className="grid sm:grid-cols-2 gap-3">
{f.inputs.map((inp) => {
const value = draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '');
const set = (v: string) => setForm((p) => ({ ...p, [f.key]: { ...(p[f.key] ?? {}), [inp.name]: v } }));
return (
<label key={inp.name} className={`flex flex-col gap-1 ${inp.type === 'textarea' ? 'sm:col-span-2' : ''}`}>
<span className="text-sm text-slate-600">{inp.label}</span>
{inp.type === 'textarea'
? <textarea value={value} onChange={(e) => set(e.target.value)} rows={3} className="border border-slate-200 rounded-lg px-3 py-2 text-base" />
: <input type={inp.type ?? 'text'} value={value} onChange={(e) => set(e.target.value)} className="border border-slate-200 rounded-lg px-3 py-2 text-base" />}
</label>
);
})}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={busy === `${f.key}|`}
onClick={() => {
const value: Record<string, unknown> = {};
for (const inp of f.inputs) { const v = (draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '')).trim(); if (v) value[inp.name] = inp.name === 'sameAs' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v; }
if (!Object.keys(value).length) { setMsg('입력값이 없습니다.'); return; }
void save(f.key, value);
}}
className="rounded-full bg-accent text-white font-semibold px-5 py-2.5 text-base disabled:opacity-50"
>
{busy === `${f.key}|` ? '저장 중' : '저장'}
</button>
</div>
</div>
);
})}
</div>
{/* 글별 검토·승인 */}
<div className="flex flex-col gap-5">
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">의료진 ID</h3>
<p className="text-sm text-slate-500 mb-3 break-keep">담당 원장·검토자 입력에 쓰는 ID 입니다. 홈페이지 의료진 페이지에서 읽었습니다.</p>
{physicians.length ? (
<ul className="text-sm text-slate-700 flex flex-col gap-1">{physicians.map((p) => <li key={p.id}><span className="font-mono text-accent">{p.id}</span> · {p.name} {p.title}</li>)}</ul>
) : <p className="text-sm text-slate-400">빌드 후 표시됩니다.</p>}
</div>
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">2. 글별 의학 검토 · 게시 승인</h3>
<p className="text-sm text-slate-500 mb-4 break-keep">검토가 끝난 글만 승인할 수 있고, 승인된 글만 색인 허용 빌드에 실립니다. 승인자는 의료기관 개설자(대표원장) 이름과 직함으로 적습니다.</p>
{posts.length === 0 && <p className="text-sm text-slate-400">빌드 후 글 목록이 표시됩니다.</p>}
<div className="flex flex-col gap-4">
{posts.map((p) => {
const rv = current('post_review', p.id)?.value as { reviewer?: string; reviewedAt?: string } | undefined;
const ap = current('post_approval', p.id)?.value as { approvedBy?: string; approvedAt?: string } | undefined;
const d = postForm[p.id] ?? { reviewer: rv?.reviewer ?? '', reviewedAt: rv?.reviewedAt ?? '', approvedBy: ap?.approvedBy ?? '', approvedAt: ap?.approvedAt ?? '' };
const setD = (patch: Partial<typeof d>) => setPostForm((prev) => ({ ...prev, [p.id]: { ...d, ...patch } }));
return (
<div key={p.id} className="border border-slate-100 rounded-xl p-4">
<p className="font-semibold text-primary-900 text-[15px] break-keep">{p.title}</p>
<p className="text-xs text-slate-400 mt-0.5 mb-3">{p.category} · {p.id}{p.pending?.length ? ` · 병원 확인 대기 ${p.pending.length}건` : ''} · {rv ? `검토 ${rv.reviewedAt}` : '검토 대기'} · {ap ? `승인 ${ap.approvedAt}` : '승인 대기'}</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<input value={d.reviewer} onChange={(e) => setD({ reviewer: e.target.value })} placeholder="검토 원장 ID" className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<input type="date" value={d.reviewedAt} onChange={(e) => setD({ reviewedAt: e.target.value })} className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<button type="button" disabled={!d.reviewer || !d.reviewedAt || busy === `post_review|${p.id}`} onClick={() => void save('post_review', { reviewer: d.reviewer, reviewedAt: d.reviewedAt }, p.id)} className="col-span-2 rounded-full border border-accent text-accent font-semibold py-1.5 disabled:opacity-40">검토 완료 기록</button>
<input value={d.approvedBy} onChange={(e) => setD({ approvedBy: e.target.value })} placeholder="승인자 (개설자 이름·직함)" className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<input type="date" value={d.approvedAt} onChange={(e) => setD({ approvedAt: e.target.value })} className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<button type="button" disabled={!rv || !d.approvedBy || !d.approvedAt || busy === `post_approval|${p.id}`} onClick={() => void save('post_approval', { approvedBy: d.approvedBy, approvedAt: d.approvedAt }, p.id)} className="col-span-2 rounded-full bg-primary-900 text-white font-semibold py-1.5 disabled:opacity-40">게시 승인 기록{!rv ? ' (검토 먼저)' : ''}</button>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
{msg && <p className="mt-6 text-sm font-semibold text-primary-900">{msg}</p>}
</div>
</section>
);
}

View File

@ -62,7 +62,7 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
transition={{ duration: 0.6 }}
>
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">
SAMPLE REPORT · {result.clinicName} 실측
SAMPLE REPORT · 실제 사례
</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">
읽을 수는 있지만, 인용할 근거가 없습니다.

View File

@ -6,6 +6,7 @@ import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
@ -354,6 +355,9 @@ export default function DiscoveryReportPage() {
</div>
</SectionWrapper>
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
<ClinicInputsPanel clinicId={result.id} />
{/* ── 6. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"

View File

@ -0,0 +1,42 @@
-- 서포터즈 자동 빌드 (v2 §7-5·§7-6, §12-3)
-- supporter_builds : 병원 URL 하나당 빌드 실행 기록. 워커가 단계마다 갱신하고 랜딩이 폴링한다.
-- supporter_inputs : 병원(고객)이 입력하는 값 (v2 §6 9항목 + 글별 검토·승인). 최신 행이 유효값.
-- RLS: 익명은 inputs insert/select 와 builds select 만. 쓰기(빌드 상태)는 service_role(워커) 전용.
create table if not exists public.supporter_builds (
id uuid primary key default gen_random_uuid(),
lead_id uuid references public.discovery_leads(id) on delete set null,
clinic_id text not null, -- 예: viewclinic. discovery 결과 id 와 같은 값
url text not null, -- 병원 홈페이지
status text not null default 'queued', -- queued | running | gate_failed | failed | preview | approved | published
phase text, -- 현재/마지막 단계 이름 (evidence, data, posts, build, gate, deploy, audit)
phases jsonb not null default '[]', -- [{phase, status, startedAt, finishedAt, summary, log}]
posts jsonb not null default '[]', -- [{id, title, category, reviewer, reviewStatus, approvedAt}]
preview_url text,
report jsonb, -- 생성기 report.summary + 게이트 결과
error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists supporter_builds_clinic_idx on public.supporter_builds (clinic_id, created_at desc);
create table if not exists public.supporter_inputs (
id uuid primary key default gen_random_uuid(),
clinic_id text not null,
key text not null, -- author | editor | sponsorship | quote_items | recovery | revision_policy | discrepancy | specialty_doctors | domain | post_review | post_approval
post_id text, -- post_review / post_approval 일 때 글 slug
value jsonb not null,
input_by text, -- 입력자 이름·직함 (병원 담당자)
created_at timestamptz not null default now()
);
create index if not exists supporter_inputs_clinic_idx on public.supporter_inputs (clinic_id, key, post_id, created_at desc);
alter table public.supporter_builds enable row level security;
alter table public.supporter_inputs enable row level security;
create policy "anon can read builds" on public.supporter_builds for select to anon using (true);
create policy "anon can read inputs" on public.supporter_inputs for select to anon using (true);
create policy "anon can insert inputs" on public.supporter_inputs for insert to anon with check (true);
comment on table public.supporter_builds is '서포터즈 사이트 자동 빌드 실행 기록. 워커(service_role)가 갱신, 랜딩 /discovery/:id 가 폴링.';
comment on table public.supporter_inputs is '병원 확인 항목 입력(v2 §6). 같은 clinic_id·key·post_id 의 최신 행이 유효값. apply_inputs.mjs 가 데이터 파일에 반영.';

View File

@ -0,0 +1,10 @@
{
"_comment": "supporter_inputs 행 예시 (v2 §6). 값은 예시이며 실제 병원 입력이 아니다. apply_inputs.mjs --inputs 로 반영 테스트에 쓴다.",
"rows": [
{ "clinic_id": "viewclinic", "key": "sponsorship", "value": { "notice": "이 글은 뷰성형외과의 지원(광고비)을 받아 뷰 서포터즈가 작성했습니다." }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:00:00Z" },
{ "clinic_id": "viewclinic", "key": "specialty_doctors", "value": { "eye": "dr-cho", "nose": "dr-ko", "contour": "dr-ohjs", "lifting": "dr-kimsy", "anesthesia": "dr-choijh" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:01:00Z" },
{ "clinic_id": "viewclinic", "key": "recovery", "value": { "answer": "가슴성형 후 사무직 출근은 수술 3~5일째부터 가능하며, 팔을 많이 쓰는 업무는 담당 원장이 상담에서 정합니다. (예시)" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:02:00Z" },
{ "clinic_id": "viewclinic", "key": "post_review", "post_id": "eye-canthoplasty-types", "value": { "reviewer": "dr-cho", "reviewedAt": "2026-09-08" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:03:00Z" },
{ "clinic_id": "viewclinic", "key": "post_approval", "post_id": "eye-canthoplasty-types", "value": { "approvedBy": "최순우 대표원장", "approvedAt": "2026-09-08" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:04:00Z" }
]
}

View File

@ -0,0 +1,141 @@
// 병원 확인 항목 입력(v2 §6, supporter_inputs) → 데이터 파일·글 frontmatter 반영. 멱등.
//
// node scripts/apply_inputs.mjs --clinic viewclinic --inputs inputs.json 로컬 파일 (배열 또는 {rows:[...]})
// node scripts/apply_inputs.mjs --clinic viewclinic --from-supabase SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY 로 조회
// --today YYYY-MM-DD --dry-run
//
// 행 형식: { clinic_id, key, post_id?, value, input_by?, created_at }
// author { name, role, sameAs[], email } → authors.supporters[site.editorId]
// editor { name, email } → site.editorId 사람 이름·corrections.owner/contact
// sponsorship { notice } → site.sponsorNotice (글 첫 부분 고지, §12-2)
// specialty_doctors { eye:'dr-x', nose:'dr-y', contour:..., lifting:..., anesthesia:... } → authors.physicians[id].specialty
// domain { domain, indexableFrom, homepageLinkPosition } → site.domain / site.indexableFrom / site.homepageLink
// quote_items · recovery · revision_policy · discrepancy → src/data/clinicAnswers.json (생성기 근거 [A], 화면 노출 없음)
// post_review post_id + { reviewer, reviewedAt } → frontmatter reviewer·reviewStatus=reviewed·reviewedAt + history
// post_approval post_id + { approvedBy, approvedAt } → frontmatter approvedBy·approvedAt + history (reviewed 전이면 거부)
// 같은 (key, post_id) 는 created_at 최신 행만 쓴다. 코드 수정 없이 데이터만으로 검토 완료·스키마가 켜지는 것이 목표(v2 §10).
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import * as R from './gate/rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
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', 'viewclinic');
const today = opt('today', new Date().toISOString().slice(0, 10));
const dryRun = flag('dry-run');
const DATA = join(SUP, 'src/data');
const POSTS = join(SUP, 'src/content/posts');
(function loadEnv() { let dir = SUP; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) 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, ''); } dir = dirname(dir); } })();
// ---------- 입력 읽기 ----------
async function loadRows() {
if (opt('inputs')) { const j = JSON.parse(readFileSync(resolve(opt('inputs')), 'utf8')); return Array.isArray(j) ? j : j.rows ?? []; }
if (flag('from-supabase')) {
const url = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.VITE_SUPABASE_ANON_KEY;
if (!url || !key) throw new Error('SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음');
const res = await fetch(`${url}/rest/v1/supporter_inputs?clinic_id=eq.${encodeURIComponent(clinic)}&order=created_at.asc`, { headers: { apikey: key, authorization: `Bearer ${key}` } });
if (!res.ok) throw new Error(`supabase ${res.status} ${await res.text()}`);
return await res.json();
}
throw new Error('--inputs <file> 또는 --from-supabase 가 필요합니다');
}
const rows = (await loadRows()).filter((r) => !r.clinic_id || r.clinic_id === clinic).sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')));
const latest = new Map(); // key|post_id → row
for (const r of rows) latest.set(`${r.key}|${r.post_id ?? ''}`, r);
const get = (key, postId = '') => latest.get(`${key}|${postId}`)?.value;
// ---------- 데이터 파일 ----------
const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8'));
const authors = readJson('authors.json');
const site = readJson('site.json');
const corrections = existsSync(join(DATA, 'corrections.json')) ? readJson('corrections.json') : { items: [] };
const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson('clinicAnswers.json') : { _comment: '병원이 서면으로 확인해 준 답변. 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.', items: [] };
const changes = [];
const note = (s) => changes.push(s);
// 1. 작성자·편집 책임·정정 이메일
const author = get('author');
if (author) {
const id = site.editorId || Object.keys(authors.supporters ?? {})[0] || 'editor';
authors.supporters = authors.supporters ?? {};
const cur = authors.supporters[id] ?? {};
authors.supporters[id] = { ...cur, name: author.name ?? cur.name, role: author.role ?? cur.role, email: author.email ?? cur.email, sameAs: author.sameAs ?? cur.sameAs ?? [] };
site.editorId = id;
note(`author → supporters.${id} (${author.name})`);
}
const editor = get('editor');
if (editor) { corrections.owner = editor.name ?? corrections.owner; corrections.contact = editor.email ?? corrections.contact; note(`editor → corrections.owner/contact`); }
// 3. 지원 관계 문구 (글 첫 부분 고지)
const sponsorship = get('sponsorship');
if (sponsorship?.notice) { site.sponsorNotice = sponsorship.notice; note('sponsorship → site.sponsorNotice'); }
// 8. 진료 분야별 담당 원장
const spec = get('specialty_doctors');
if (spec) {
const LABEL = { eye: '눈성형', nose: '코성형', contour: '안면윤곽', lifting: '리프팅', breast: '가슴성형', anesthesia: '마취', revision: '재수술' };
for (const [field, id] of Object.entries(spec)) {
if (!id || !authors.physicians?.[id]) { note(`specialty_doctors.${field}: "${id}" 는 physicians 에 없음 (건너뜀)`); continue; }
authors.physicians[id].specialty = LABEL[field] ?? field;
note(`specialty_doctors.${field} → ${id}`);
}
}
// 9. 도메인·색인 시점·홈페이지 링크 위치
const domain = get('domain');
if (domain) { Object.assign(site, { domain: domain.domain ?? site.domain, indexableFrom: domain.indexableFrom ?? site.indexableFrom, homepageLink: domain.homepageLinkPosition ?? site.homepageLink }); note('domain → site.domain/indexableFrom/homepageLink'); }
// 4~7. 자유 답변 → clinicAnswers (근거 [A])
const QUESTION = { quote_items: '견적 기본 포함 항목과 결제 방식', recovery: '회복 일정표의 병원 확정값(출근 기준 등)', revision_policy: '재수술 정책(타원 수술 상담 가능 여부, 부위별 대기 기간)', discrepancy: '페이지 간 표기 불일치의 정답' };
for (const key of ['quote_items', 'recovery', 'revision_policy', 'discrepancy']) {
const v = get(key); if (!v) continue;
const row = latest.get(`${key}|`);
const item = { key, question: v.question ?? QUESTION[key], answer: typeof v === 'string' ? v : v.answer ?? JSON.stringify(v), at: String(row.created_at ?? today).slice(0, 10), by: row.input_by ?? '병원' };
answers.items = (answers.items ?? []).filter((x) => x.key !== key).concat(item);
note(`${key} → clinicAnswers`);
}
// 2. 글별 의학 검토 · 개설자 게시 승인 → frontmatter
const postFiles = existsSync(POSTS) ? readdirSync(POSTS).filter((f) => f.endsWith('.md')) : [];
for (const f of postFiles) {
const id = f.replace(/\.md$/, '');
const review = get('post_review', id); const approval = get('post_approval', id);
if (!review && !approval) continue;
const src = readFileSync(join(POSTS, f), 'utf8');
const { data, body } = R.parseMarkdown(src);
let changed = false;
if (review?.reviewer && review?.reviewedAt) {
if (!authors.physicians?.[review.reviewer]) { note(`${id}: reviewer "${review.reviewer}" 가 physicians 에 없음 (건너뜀)`); }
else if (data.reviewer !== review.reviewer || data.reviewedAt !== review.reviewedAt || data.reviewStatus !== 'reviewed') {
data.reviewer = review.reviewer; data.reviewedAt = review.reviewedAt; data.reviewStatus = 'reviewed'; data.dateModified = today;
data.history = [...(data.history ?? []), { date: review.reviewedAt, note: `담당 원장 의학 검토 완료 (${authors.physicians[review.reviewer].name})` }];
changed = true; note(`${id}: 의학 검토 ${review.reviewer} ${review.reviewedAt}`);
}
}
if (approval?.approvedBy && approval?.approvedAt) {
if (data.reviewStatus !== 'reviewed') note(`${id}: 의학 검토 전이라 게시 승인 보류`);
else if (data.approvedAt !== approval.approvedAt || data.approvedBy !== approval.approvedBy) {
data.approvedBy = approval.approvedBy; data.approvedAt = approval.approvedAt; data.dateModified = today;
data.history = [...(data.history ?? []), { date: approval.approvedAt, note: `게시 승인 (${approval.approvedBy})` }];
changed = true; note(`${id}: 게시 승인 ${approval.approvedBy} ${approval.approvedAt}`);
}
}
if (changed && !dryRun) writeFileSync(join(POSTS, f), `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${body.trim()}\n`);
}
// ---------- 저장 ----------
if (!dryRun) {
writeFileSync(join(DATA, 'authors.json'), JSON.stringify(authors, null, 2) + '\n');
writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n');
writeFileSync(join(DATA, 'corrections.json'), JSON.stringify(corrections, null, 2) + '\n');
writeFileSync(join(DATA, 'clinicAnswers.json'), JSON.stringify(answers, null, 2) + '\n');
}
console.log(`입력 ${rows.length}행 · 유효 ${latest.size}건 · 반영 ${changes.length}건${dryRun ? ' (dry-run)' : ''}`);
for (const c of changes) console.log(' ·', c);

View File

@ -23,6 +23,8 @@ const HOME_TEXT = existsSync(homeTextPath) ? readFileSync(homeTextPath, '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 site = JSON.parse(readFileSync(here('../src/data/site.json'), 'utf8'));
const INDEXABLE = process.env.PUBLIC_INDEXABLE === 'true';
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
// 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙).
@ -65,6 +67,14 @@ for (const f of files) {
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
add(rel, R.checkOperatorVocab(text, rel));
add(rel, R.checkHeadingEndings(html));
// §12-2 (1) 글 첫 부분 지원 고지. site.sponsorNotice 가 켜져 있으면 error, 아니면 warn
if (rel.startsWith('/posts/') && rel !== '/posts.html') add(rel, R.checkTopDisclosure(html, { required: Boolean(site.sponsorNotice) }));
// §12-2 (2) 색인 허용 빌드에 미승인 글이 섞이면 error
if (INDEXABLE && rel.startsWith('/posts/') && rel !== '/posts.html') {
const slug = rel.replace(/^\/posts\//, '').replace(/\.html$/, '');
const src = join(POSTS, `${slug}.md`);
if (existsSync(src) && !R.parseMarkdown(readFileSync(src, 'utf8')).data.approvedAt) add(rel, [{ level: R.E, code: 'UNAPPROVED_IN_INDEXABLE_BUILD', msg: '색인 허용 빌드인데 게시 승인(approvedAt) 없는 글이 포함됨' }]);
}
// 구조 지문
if (LAYOUT_PAGES[rel]) layoutNow[rel] = R.layoutFingerprint(html);
}

View File

@ -0,0 +1,78 @@
// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11).
// 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다.
// 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다.
//
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6]
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
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 clinic = opt('clinic'); const EV = resolve(opt('evidence', `../evidence/${clinic}`)); const SITE = resolve(opt('site', '.'));
const max = Number(opt('max', '6'));
if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic 과 --evidence <dir> (index.json 있는 폴더) 필요'); process.exit(2); }
const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
const fact = JSON.parse(readFileSync(join(SITE, 'src/data/factSheet.json'), 'utf8'));
const authors = JSON.parse(readFileSync(join(SITE, 'src/data/authors.json'), 'utf8'));
const name = fact.shortName || fact.name || clinic;
const pages = index.pages.filter((p) => p.status === 200 && p.chars >= 200);
const byType = (t) => pages.filter((p) => p.type === t).sort((a, b) => b.chars - a.chars);
const urls = (...types) => types.flatMap((t) => byType(t).slice(0, 3).map((p) => p.url));
const physicianIds = Object.keys(authors.physicians ?? {});
const rep = physicianIds.find((id) => /대표/.test(authors.physicians[id].title ?? '')) ?? physicianIds[0];
const candidates = [
{
id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'],
title: `${name}은 어떤 병원인가요? 위치·진료 분야·의료진 구성`,
intent: '병원 소개·진료과·의료진 수·건물·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장 수).',
need: ['about', 'doctor', 'direction'], pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep,
},
{
id: 'visit-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-01', 'B-03', 'B-05'],
title: `${name} 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?`,
intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길 페이지만 근거로. 확인되지 않은 시간은 "전화 확인".',
need: ['direction'], pages: urls('direction', 'reservation'), reviewer: null,
},
{
id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'],
title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`,
intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
need: ['safety'], pages: urls('safety', 'checkup'), reviewer: physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep,
},
{
id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'],
title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?',
intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
need: ['precautions'], pages: urls('precautions', 'aftercare'), reviewer: rep,
},
{
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.',
need: ['safety', 'aftercare'], pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep,
},
{
id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'],
title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?',
intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".',
need: ['aftercare'], pages: urls('aftercare', 'checkup'), reviewer: rep,
},
];
const posts = candidates
.filter((c) => c.need.every((t) => byType(t).length) && c.pages.length)
.slice(0, max)
.map(({ need, pages: pg, reviewer, ...c }) => ({
...c,
avoid: ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담'],
reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: [], gallery: [],
}));
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', posts };
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));
writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n');
console.log(`brief ${posts.length}편 → ${outPath}${posts.length < candidates.length ? ` (근거 부족으로 제외 ${candidates.length - posts.length}편: ${candidates.filter((c) => !posts.find((p) => p.id === c.id)).map((c) => c.id).join(', ')})` : ''}`);

View File

@ -0,0 +1,12 @@
---
expect: [APPROVED_WITHOUT_BY, APPROVED_BEFORE_REVIEW]
approvedAt: "2026-09-07"
reviewStatus: pending
---
## 게시 승인 필드 검사
이 픽스처는 승인자 없이, 의학 검토 전에 게시 승인이 찍힌 경우입니다. 상담에서 확인하세요.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |

View File

@ -112,7 +112,7 @@ export function checkSourcesType(data) {
}
/** FDA·ISO·승인·연구 주장이 있으면 regulation 타입 원문 소스가 있어야 한다. 학회명(연구회·연구소)은 제외. */
export const REGULATION_CLAIM = /FDA|ISO\s?\d|승인|연구(?!회|소|원|팀|실)/;
export const REGULATION_CLAIM = /FDA|ISO\s?\d|(?<!게시\s?)승인(?!자|일)|연구(?!회|소|원|팀|실)/; // "게시 승인"(§12-2 개설자 승인)은 규제 주장이 아니다
export function checkRegulationSource(data, fields) {
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
if (hasReg) return [];
@ -214,9 +214,30 @@ export function checkReview(data, physicians = {}, supporters = {}) {
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 없음'));
}
// 게시 승인(v2 §12-2): 승인일이 있으면 승인자(개설자·직함)도 있어야 하고, 의학 검토가 끝난 글만 승인할 수 있다.
if (data.approvedAt && !data.approvedBy) out.push(r(E, 'APPROVED_WITHOUT_BY', 'approvedAt 이 있는데 approvedBy(승인자) 없음'));
if (data.approvedBy && !data.approvedAt) out.push(r(E, 'APPROVED_WITHOUT_DATE', 'approvedBy 가 있는데 approvedAt 없음'));
if (data.approvedAt && data.reviewStatus !== 'reviewed') out.push(r(E, 'APPROVED_BEFORE_REVIEW', '의학 검토(reviewed) 전에 게시 승인됨'));
return out;
}
// ---------- §12-2 (3) 기자·전문가 화법 (의료법 56조 2항 10호 대응). 경고 수준 ----------
export const PRESS_VOICE = /본지|기자(?!회견)|전문가에 따르면|업계에서는|업계 관계자/;
export function checkPressVoice(text, field = '') {
const m = text.match(PRESS_VOICE);
return m ? [r(W, 'PRESS_VOICE', `${field ? field + ': ' : ''}기자·전문가 화법: "${m[0]}"`)] : [];
}
// ---------- §12-2 (1) 지원 관계 고지의 상단 배치 ----------
/** 글 페이지 HTML 에서 상단 고지(.disclosure-top)가 세 줄 요약(.summary) 앞에 있어야 한다. notice 가 비어 있으면(아직 미승인) 경고만. */
export function checkTopDisclosure(html, { required = false } = {}) {
const top = html.search(/class="[^"]*disclosure-top[^"]*"/);
const summary = html.search(/class="[^"]*\bsummary\b[^"]*"/);
if (top >= 0 && (summary < 0 || top < summary)) return [];
const level = required ? E : W;
return [r(level, 'DISCLOSURE_TOP_MISSING', top < 0 ? '글 첫 부분에 지원 관계 고지 없음 (site.sponsorNotice 를 채우면 렌더됨)' : '지원 관계 고지가 세 줄 요약보다 아래에 있음')];
}
// ---------- §3-8 싣지 않는 것 ----------
export const BANNED_VIDEO_TITLE = /전후|리뷰|성공|번호따|변신|역대급/;
export function checkVideoTitles(list, { level = E, label = '' } = {}) {
@ -280,5 +301,6 @@ export function checkPostSource({ data, body }, { physicians, supporters } = {})
...checkVideoTitles([...(data.videos ?? []), ...(data.video ? [data.video] : [])], { label: 'frontmatter' }),
...checkOriginalContent(data, body),
...fields.flatMap((f) => checkPlainEndings(f.text, f.field)),
...fields.flatMap((f) => checkPressVoice(f.text, f.field)),
];
}

View File

@ -56,7 +56,7 @@ const SOURCE_TYPE_LABEL = { clinic: '병원 제공', doctor: '원장 설명', pr
* brief 하나에 대한 컨텍스트 문자열과 sources 배열을 만든다.
* brief.evidence = { pages: [url], shorts: [videoId], transcripts: [{id,title,speaker,text}], news: [url], regulation: [{label,url,note}], platform: bool }
*/
export function buildContext(brief, { ev, fact, authors, videos, news, today }) {
export function buildContext(brief, { ev, fact, authors, videos, news, answers, today }) {
const parts = [];
const missing = [];
const sources = [];
@ -111,6 +111,13 @@ export function buildContext(brief, { ev, fact, authors, videos, news, today })
});
parts.push(`## 4. 규제·연구 원문\n\n${rparts.length ? rparts.join('\n\n') : '(규제·연구 근거 없음. FDA·ISO·승인·연구를 언급하지 않는다)'}`);
// 4b. 병원이 서면으로 확인해 준 답변 (apply_inputs → clinicAnswers.json). §6 4~7항. 병원 제공 근거로 친다.
const aparts = (answers?.items ?? []).map((a, i) => `### [A${i + 1}] ${a.question ?? a.key} (병원 확인 ${a.at}${a.by ? `, ${a.by}` : ''})\n${a.answer}`);
if (aparts.length) {
parts.push(`## 4b. 병원 확인 답변 (병원이 서면으로 확인한 값. "병원 확인 기준"으로 인용한다)\n\n${aparts.join('\n\n')}`);
pushSource({ label: `${clinicName} 서면 확인 답변 (${(answers.items.at(-1)?.at) ?? today})`, url: fact?.url ?? '', accessed: today, type: 'clinic' });
}
// 5. 팩트 시트 (플랫폼 집계 포함)
if (fact) {
const slim = { ...fact };

View File

@ -8,9 +8,6 @@ export function toMarkdown(data, body) {
return `---\n${fm}---\n${String(body).trim()}\n`;
}
/** 기자·전문가 화법 (v2 §12-2 (3), 의료법 56조 2항 10호 대응). 경고 수준. */
export const PRESS_VOICE = /본지|기자(?!회견)|전문가에 따르면|업계에서는|업계 관계자/;
/**
* 글 한 편의 소스 수준 게이트. dist 없이 frontmatter·본문만 본다.
* 반환 { errors, warns, stats }. errors 가 있으면 발행 불가.
@ -43,8 +40,6 @@ export function gatePost(md, { physicians = {}, supporters = {}, homeText = '' }
const tableCount = (body.match(/^\|\s*:?-+/gm) ?? []).length;
if (!tableCount) findings.push({ level: R.W, code: 'NO_TABLE', msg: '본문에 표가 없음 (1개 이상 권장)' });
if (!/\?\s*$/.test(String(data.title ?? '')) && !/(표|목록|체크리스트|기록)\s*$/.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'TITLE_NOT_QUESTION', msg: `제목이 질문형이 아님: "${data.title}"` });
const press = fullText.match(PRESS_VOICE);
if (press) findings.push({ level: R.W, code: 'PRESS_VOICE', msg: `기자·전문가 화법: "${press[0]}"` });
const desc = String(data.description ?? '');
if (desc.length < 60 || desc.length > 200) findings.push({ level: R.W, code: 'DESCRIPTION_LENGTH', msg: `description ${desc.length}자 (90~160자 권장)` });

View File

@ -62,14 +62,17 @@ function loadEnv() {
loadEnv();
// ---------- 데이터 ----------
const DATA = join(SUP, 'src/data');
const POSTS = join(SUP, 'src/content/posts');
// --site <dir> : 다른 사이트 디렉터리(템플릿 복제본)의 데이터·글을 쓴다. 워커가 병원별 작업 폴더로 돌릴 때.
const SITE = resolve(opt('site', SUP));
const DATA = join(SITE, 'src/data');
const POSTS = join(SITE, 'src/content/posts');
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const fact = readJson(join(DATA, 'factSheet.json'));
const authors = readJson(join(DATA, 'authors.json'));
const videos = readJson(join(DATA, 'videos.json'));
const news = existsSync(join(DATA, 'news.json')) ? readJson(join(DATA, 'news.json')) : { items: [] };
const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson(join(DATA, 'clinicAnswers.json')) : { items: [] };
if (!existsSync(join(EV, 'index.json'))) { console.error(`근거 없음: ${EV}. 먼저 node scripts/collect_evidence.mjs --clinic ${clinic} --url <홈>`); process.exit(1); }
const ev = loadEvidence(EV);
const authorId = opt('author', Object.keys(authors.supporters ?? {})[0]);
@ -252,7 +255,7 @@ for (const brief of briefs) {
report.posts.push(row);
if (shouldStop({ spentUsd: spent })) { row.status = 'skipped_cost_cap'; console.log(` – ${brief.id}: 비용 상한 도달, 건너뜀`); continue; }
const ctx = buildContext(brief, { ev, fact, authors, videos, news, today });
const ctx = buildContext(brief, { ev, fact, authors, videos, news, answers, today });
row.missingEvidence = ctx.missing;
row.contextChars = ctx.text.length;
const reviewerName = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate]?.name;

View File

@ -0,0 +1,4 @@
{
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
"items": []
}

View File

@ -20,5 +20,6 @@
"arrivalGuide": "",
"visitReads": [],
"newsNote": "",
"newsOutletsExample": ""
"newsOutletsExample": "",
"sponsorNotice": ""
}

View File

@ -0,0 +1,6 @@
---
// 글 첫 부분의 지원 관계 고지 (v2 §12-2, 추천·보증 심사지침: 제목 또는 첫 부분에 표시).
// site.sponsorNotice 가 비어 있으면 아무것도 렌더하지 않는다. 켜는 것은 haewon 승인 후.
import { SPONSOR_NOTICE } from '../lib';
---
{SPONSOR_NOTICE && <p class="disclosure disclosure-top">{SPONSOR_NOTICE}</p>}

View File

@ -15,6 +15,8 @@ const posts = defineCollection({
reviewer: z.string().optional(), // 감수자 id (physicians)
reviewStatus: z.enum(['reviewed', 'pending']).default('pending'),
reviewedAt: z.string().optional(), // 의학 검토 완료일. reviewed일 때 필수로 쓴다
approvedBy: z.string().optional(), // 게시 승인자(의료기관 개설자·직함). v2 §12-2. 병원 입력으로만 채운다
approvedAt: z.string().optional(), // 게시 승인일. 색인 허용 빌드(PUBLIC_INDEXABLE=true)는 이 값이 있는 글만 낸다
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(),

View File

@ -0,0 +1,4 @@
{
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
"items": []
}

View File

@ -15,6 +15,11 @@ export const CATEGORY_LABELS: Record<string, string> = {
export { fact, authors, site };
/** 지원 관계 고지(글 첫 부분). site.sponsorNotice 가 비어 있으면 상단 고지를 렌더하지 않는다(v2 §12-2, haewon 승인 후 켠다). */
export const SPONSOR_NOTICE: string = (site as AnyRec).sponsorNotice || '';
/** 색인 허용 빌드에서는 개설자 게시 승인(approvedAt)이 있는 글만 낸다. 미리보기(noindex)는 전부. */
export const isPublishable = (p: { data: { approvedAt?: string } }): boolean => !INDEXABLE || has(p.data.approvedAt);
/** 비어 있으면 PENDING. 화면 표시용. */
export const v = (x: unknown, fallback: string = PENDING): string => (x === null || x === undefined || x === '' ? fallback : String(x));
/** 비어 있지 않은 값만. 스키마·링크용. */

View File

@ -2,13 +2,13 @@
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
import { authors, personSchema } from '../../lib';
import { authors, personSchema, isPublishable } 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 posts = (await getCollection('posts')).filter(isPublishable).filter((p) => p.data.author === id);
const site = Astro.site!.toString().replace(/\/$/, '');
---
<Base title={`${a.name} · 작성 글`} description={a.bio} jsonLd={personSchema(id!, site)}>

View File

@ -5,8 +5,8 @@ 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));
import { SITE_NAME, fact, site as S, clinicSchema, surface, v, has, PENDING, isPublishable } from '../lib';
const posts = (await getCollection('posts')).filter(isPublishable).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, '병원');

View File

@ -11,10 +11,11 @@ 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';
import DisclosureTop from '../../components/DisclosureTop.astro';
import { CATEGORY_LABELS, SITE_NAME, clinicSchema, personSchema, physicianSchema, physician, supporter, fact, surface, isPublishable } from '../../lib';
export async function getStaticPaths() {
const posts = await getCollection('posts');
const posts = (await getCollection('posts')).filter(isPublishable);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
@ -60,10 +61,12 @@ if (d.faq.length) graph.push({ '@type': 'FAQPage', '@id': `${url}#faq`, mainEnti
<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>}
{d.approvedAt && <span>게시 승인 {d.approvedBy ?? ''} ({d.approvedAt})</span>}
<span>발행 {d.datePublished}</span>
<span>갱신 {d.dateModified}</span>
</div>
</header>
<DisclosureTop />
<Summary items={d.summary} />
{d.hero && (
<figure class="figure">

View File

@ -2,8 +2,8 @@
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));
import { CATEGORY_LABELS, SITE_NAME, fact, v, isPublishable } from '../../lib';
const posts = (await getCollection('posts')).filter(isPublishable).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 })) };

View File

@ -1,9 +1,10 @@
import type { APIRoute } from 'astro';
import { isPublishable } from '../lib';
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 posts = (await getCollection('posts')).filter(isPublishable);
const today = new Date().toISOString().slice(0, 10);
const urls = [
{ loc: `${base}/`, lastmod: today },

View File

@ -23,3 +23,6 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
.vercel
# 글 생성기 산출물 (리포트는 docs 로 옮겨 커밋)
generated/

View File

@ -0,0 +1,54 @@
# 기획 목록(brief) 형식
글 생성기(`scripts/generate_posts.mjs`)의 입력이다. v2 §3-11의 "기획 목록 승인"은 이 파일을 사람이 확인하는 것으로 한다. 생성기는 brief 밖의 근거를 쓰지 않는다.
```
node scripts/generate_posts.mjs --clinic <id> --brief briefs/<id>/<name>.json [--out DIR] [--dry-run] [--no-verify]
node scripts/generate_posts.mjs --clinic viewclinic --from-golden # 정답지 18편에서 brief 를 자동 추출해 회귀
```
## 파일
```json
{
"posts": [
{
"id": "slug", // 파일명. posts/<id>.md
"title": "질문형 한 문장?", // H1. 물음표로 끝난다
"category": "D", "categoryLabel": "시술 정보",
"qbIds": ["D2-02"], // 질문 뱅크 문항 ID (화면에는 안 나옴)
"intent": "무엇을 어떤 형식으로 정리할지 한 문단", // 선택
"mustCover": ["..."], "avoid": ["..."], // 선택
"reviewerCandidate": "dr-cho", // authors.json physicians 키. 후보일 뿐, reviewStatus 는 항상 pending
"evidence": {
"pages": ["https://.../system/precautions/"], // 병원 페이지. evidence/<clinic>/pages 에 있어야 함
"shorts": ["videoId"], // videos.json shortsInfo 에 정리 답이 있는 영상
"transcripts": [{ "id": "", "title": "", "speaker": "", "text": "" }], // 선택. 자막이 있으면 여기로
"news": ["https://..."], // news.json 에 있는 기사 URL (제목·매체·날짜만 쓴다)
"regulation": [{ "label": "", "url": "", "note": "확인된 내용 한 줄" }], // note 가 없으면 링크로만 안내
"platform": false // true 면 팩트 시트 surfaces 를 sources 에 붙인다
},
"videos": [{ "id": "", "title": "", "speaker": "", "published": "", "note": "" }], // 글에 붙는 영상 (제목·화자만)
"hero": { "src": "/img/...", "alt": "", "caption": "" }, // 선택
"gallery": [], "thumbnail": "", // 선택
"sources": [] // 주면 evidence 로부터의 자동 조립 대신 이것을 쓴다 (정답지 회귀용)
}
]
}
```
## 생성 흐름
1. 컨텍스트: evidence 를 §4 순서(병원 페이지 → 영상 정리 답 → 기사 → 규제 원문 → 팩트 시트)로 붙인다. 없는 근거는 `missingEvidence` 로 리포트에 남긴다.
2. 생성(1회): 요약 3·본문·FAQ·태그·pending(근거에 없어 못 쓴 것)·usedRefs.
3. 후처리: 근거 태그(`[C1]`) 제거, 본문의 FAQ·참고 자료·연락처 섹션 제거(템플릿이 붙인다).
4. 검사: 소스 게이트(`gate/rules.mjs` 전부 + 금칙어 + 비교 수치 + 홈페이지 40자 중복) + 수치 대조(글의 숫자+단위가 근거에 그대로 있어야 함) + 모델 근거 대조(`--no-verify` 로 생략).
5. 오류가 있거나 경고가 `--warn-threshold`(기본 4) 이상이면 오류 목록을 되돌려 수정 1회. 그래도 남은 근거 플래그는 `ok_needs_review` 로 표시해 사람이 본다.
6. 저장: `generated/<clinic>/<시각>/posts/*.md` + `report.md`(편당 비용·시간·오류·확인 대기 목록). `generated/` 는 커밋하지 않는다. 채택할 글은 사람이 `src/content/posts/` 로 옮긴다.
## 생성기가 하지 않는 것
- `reviewedAt`·`reviewStatus: reviewed` 를 쓰지 않는다 (§3-7 사람 게이트).
- 자막 없는 영상의 발언을 만들지 않는다. 영상 근거는 `shortsInfo[].answer` 또는 `transcripts` 뿐이다.
- 기사 본문을 추정하지 않는다. 제목·매체·날짜만.
- 근거에 없는 수치를 쓰지 않는다. 쓰면 `NUMBER_NOT_IN_EVIDENCE` 로 수정 대상이 된다.

View File

@ -0,0 +1,33 @@
{
"_comment": "기획 목록(brief) 예시. 정답지에 없는 새 문항. evidence 의 URL·영상 ID는 근거 수집 결과(evidence/viewclinic)와 videos.json 에 있어야 한다. sources 는 여기서 주지 않으면 evidence 로부터 자동 조립된다. 검토자는 후보일 뿐이고 reviewStatus 는 항상 pending 으로 나온다.",
"posts": [
{
"id": "eye-double-eyelid-methods",
"title": "쌍꺼풀 수술은 자연유착·절개·콤비매몰 중 무엇을 어떤 기준으로 고르나요?",
"category": "D",
"categoryLabel": "시술 정보",
"qbIds": ["D2-02", "D2-03", "D2-06"],
"intent": "세 방식의 차이(절개 여부·붓기·회복·풀림 가능성·적합한 눈 조건)를 조건 표로 정리하고, 회복 일정은 주의사항 페이지의 눈 부분 문장만 근거로 쓴다. 방식 선택은 진료에서 정한다고 닫는다.",
"mustCover": ["방식 비교 표(절개 여부·흉터·붓기·회복·풀림)", "수술 후 주의사항의 눈 부분(실밥·세안·렌즈·붓기)", "소시지눈과 단순 부기의 구별(쇼츠 정리 답 범위 안에서)"],
"avoid": ["전후 사진", "가격", "다른 병원 비교", "효과 보장"],
"reviewerCandidate": "dr-cho",
"evidence": {
"pages": [
"https://www.viewclinic.com/eye/double-eyelid/natural-fold/",
"https://www.viewclinic.com/eye/double-eyelid/incisional-method/",
"https://www.viewclinic.com/eye/double-eyelid/combo-non-incisional/",
"https://www.viewclinic.com/system/precautions/",
"https://www.viewclinic.com/system/after-care/"
],
"shorts": ["1OxjYxwciXw"],
"news": [],
"regulation": [],
"platform": false
},
"videos": [
{ "id": "1OxjYxwciXw", "title": "[눈성형] 소시지눈과 단순부기, 어떻게 구별하나요?", "published": "2025-02-20", "note": "붓기 구별 설명" }
],
"gallery": []
}
]
}

View File

@ -0,0 +1,10 @@
{
"_comment": "supporter_inputs 행 예시 (v2 §6). 값은 예시이며 실제 병원 입력이 아니다. apply_inputs.mjs --inputs 로 반영 테스트에 쓴다.",
"rows": [
{ "clinic_id": "viewclinic", "key": "sponsorship", "value": { "notice": "이 글은 뷰성형외과의 지원(광고비)을 받아 뷰 서포터즈가 작성했습니다." }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:00:00Z" },
{ "clinic_id": "viewclinic", "key": "specialty_doctors", "value": { "eye": "dr-cho", "nose": "dr-ko", "contour": "dr-ohjs", "lifting": "dr-kimsy", "anesthesia": "dr-choijh" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:01:00Z" },
{ "clinic_id": "viewclinic", "key": "recovery", "value": { "answer": "가슴성형 후 사무직 출근은 수술 3~5일째부터 가능하며, 팔을 많이 쓰는 업무는 담당 원장이 상담에서 정합니다. (예시)" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:02:00Z" },
{ "clinic_id": "viewclinic", "key": "post_review", "post_id": "eye-canthoplasty-types", "value": { "reviewer": "dr-cho", "reviewedAt": "2026-09-08" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:03:00Z" },
{ "clinic_id": "viewclinic", "key": "post_approval", "post_id": "eye-canthoplasty-types", "value": { "approvedBy": "최순우 대표원장", "approvedAt": "2026-09-08" }, "input_by": "예시 담당자", "created_at": "2026-09-08T09:04:00Z" }
]
}

View File

@ -11,7 +11,8 @@
"astro": "^7.3.1"
},
"devDependencies": {
"js-yaml": "^4.3.2"
"js-yaml": "^4.3.2",
"node-html-parser": "^7.1.0"
},
"engines": {
"node": ">=22.12.0"
@ -2610,6 +2611,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@ -3145,6 +3156,47 @@
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT"
},
"node_modules/node-html-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
"integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"he": "1.2.0"
}
},
"node_modules/node-html-parser/node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/node-html-parser/node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/node-mock-http": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz",

View File

@ -12,7 +12,9 @@
"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"
"export:template": "node scripts/export_template.mjs",
"generate": "node scripts/generate_posts.mjs",
"generate:regress": "node scripts/generate_posts.mjs --clinic viewclinic --from-golden"
},
"dependencies": {
"astro": "^7.3.1"
@ -21,6 +23,7 @@
"esbuild": true
},
"devDependencies": {
"js-yaml": "^4.3.2"
"js-yaml": "^4.3.2",
"node-html-parser": "^7.1.0"
}
}

View File

@ -0,0 +1,141 @@
// 병원 확인 항목 입력(v2 §6, supporter_inputs) → 데이터 파일·글 frontmatter 반영. 멱등.
//
// node scripts/apply_inputs.mjs --clinic viewclinic --inputs inputs.json 로컬 파일 (배열 또는 {rows:[...]})
// node scripts/apply_inputs.mjs --clinic viewclinic --from-supabase SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY 로 조회
// --today YYYY-MM-DD --dry-run
//
// 행 형식: { clinic_id, key, post_id?, value, input_by?, created_at }
// author { name, role, sameAs[], email } → authors.supporters[site.editorId]
// editor { name, email } → site.editorId 사람 이름·corrections.owner/contact
// sponsorship { notice } → site.sponsorNotice (글 첫 부분 고지, §12-2)
// specialty_doctors { eye:'dr-x', nose:'dr-y', contour:..., lifting:..., anesthesia:... } → authors.physicians[id].specialty
// domain { domain, indexableFrom, homepageLinkPosition } → site.domain / site.indexableFrom / site.homepageLink
// quote_items · recovery · revision_policy · discrepancy → src/data/clinicAnswers.json (생성기 근거 [A], 화면 노출 없음)
// post_review post_id + { reviewer, reviewedAt } → frontmatter reviewer·reviewStatus=reviewed·reviewedAt + history
// post_approval post_id + { approvedBy, approvedAt } → frontmatter approvedBy·approvedAt + history (reviewed 전이면 거부)
// 같은 (key, post_id) 는 created_at 최신 행만 쓴다. 코드 수정 없이 데이터만으로 검토 완료·스키마가 켜지는 것이 목표(v2 §10).
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import * as R from './gate/rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
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', 'viewclinic');
const today = opt('today', new Date().toISOString().slice(0, 10));
const dryRun = flag('dry-run');
const DATA = join(SUP, 'src/data');
const POSTS = join(SUP, 'src/content/posts');
(function loadEnv() { let dir = SUP; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) 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, ''); } dir = dirname(dir); } })();
// ---------- 입력 읽기 ----------
async function loadRows() {
if (opt('inputs')) { const j = JSON.parse(readFileSync(resolve(opt('inputs')), 'utf8')); return Array.isArray(j) ? j : j.rows ?? []; }
if (flag('from-supabase')) {
const url = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.VITE_SUPABASE_ANON_KEY;
if (!url || !key) throw new Error('SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음');
const res = await fetch(`${url}/rest/v1/supporter_inputs?clinic_id=eq.${encodeURIComponent(clinic)}&order=created_at.asc`, { headers: { apikey: key, authorization: `Bearer ${key}` } });
if (!res.ok) throw new Error(`supabase ${res.status} ${await res.text()}`);
return await res.json();
}
throw new Error('--inputs <file> 또는 --from-supabase 가 필요합니다');
}
const rows = (await loadRows()).filter((r) => !r.clinic_id || r.clinic_id === clinic).sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')));
const latest = new Map(); // key|post_id → row
for (const r of rows) latest.set(`${r.key}|${r.post_id ?? ''}`, r);
const get = (key, postId = '') => latest.get(`${key}|${postId}`)?.value;
// ---------- 데이터 파일 ----------
const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8'));
const authors = readJson('authors.json');
const site = readJson('site.json');
const corrections = existsSync(join(DATA, 'corrections.json')) ? readJson('corrections.json') : { items: [] };
const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson('clinicAnswers.json') : { _comment: '병원이 서면으로 확인해 준 답변. 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.', items: [] };
const changes = [];
const note = (s) => changes.push(s);
// 1. 작성자·편집 책임·정정 이메일
const author = get('author');
if (author) {
const id = site.editorId || Object.keys(authors.supporters ?? {})[0] || 'editor';
authors.supporters = authors.supporters ?? {};
const cur = authors.supporters[id] ?? {};
authors.supporters[id] = { ...cur, name: author.name ?? cur.name, role: author.role ?? cur.role, email: author.email ?? cur.email, sameAs: author.sameAs ?? cur.sameAs ?? [] };
site.editorId = id;
note(`author → supporters.${id} (${author.name})`);
}
const editor = get('editor');
if (editor) { corrections.owner = editor.name ?? corrections.owner; corrections.contact = editor.email ?? corrections.contact; note(`editor → corrections.owner/contact`); }
// 3. 지원 관계 문구 (글 첫 부분 고지)
const sponsorship = get('sponsorship');
if (sponsorship?.notice) { site.sponsorNotice = sponsorship.notice; note('sponsorship → site.sponsorNotice'); }
// 8. 진료 분야별 담당 원장
const spec = get('specialty_doctors');
if (spec) {
const LABEL = { eye: '눈성형', nose: '코성형', contour: '안면윤곽', lifting: '리프팅', breast: '가슴성형', anesthesia: '마취', revision: '재수술' };
for (const [field, id] of Object.entries(spec)) {
if (!id || !authors.physicians?.[id]) { note(`specialty_doctors.${field}: "${id}" 는 physicians 에 없음 (건너뜀)`); continue; }
authors.physicians[id].specialty = LABEL[field] ?? field;
note(`specialty_doctors.${field} → ${id}`);
}
}
// 9. 도메인·색인 시점·홈페이지 링크 위치
const domain = get('domain');
if (domain) { Object.assign(site, { domain: domain.domain ?? site.domain, indexableFrom: domain.indexableFrom ?? site.indexableFrom, homepageLink: domain.homepageLinkPosition ?? site.homepageLink }); note('domain → site.domain/indexableFrom/homepageLink'); }
// 4~7. 자유 답변 → clinicAnswers (근거 [A])
const QUESTION = { quote_items: '견적 기본 포함 항목과 결제 방식', recovery: '회복 일정표의 병원 확정값(출근 기준 등)', revision_policy: '재수술 정책(타원 수술 상담 가능 여부, 부위별 대기 기간)', discrepancy: '페이지 간 표기 불일치의 정답' };
for (const key of ['quote_items', 'recovery', 'revision_policy', 'discrepancy']) {
const v = get(key); if (!v) continue;
const row = latest.get(`${key}|`);
const item = { key, question: v.question ?? QUESTION[key], answer: typeof v === 'string' ? v : v.answer ?? JSON.stringify(v), at: String(row.created_at ?? today).slice(0, 10), by: row.input_by ?? '병원' };
answers.items = (answers.items ?? []).filter((x) => x.key !== key).concat(item);
note(`${key} → clinicAnswers`);
}
// 2. 글별 의학 검토 · 개설자 게시 승인 → frontmatter
const postFiles = existsSync(POSTS) ? readdirSync(POSTS).filter((f) => f.endsWith('.md')) : [];
for (const f of postFiles) {
const id = f.replace(/\.md$/, '');
const review = get('post_review', id); const approval = get('post_approval', id);
if (!review && !approval) continue;
const src = readFileSync(join(POSTS, f), 'utf8');
const { data, body } = R.parseMarkdown(src);
let changed = false;
if (review?.reviewer && review?.reviewedAt) {
if (!authors.physicians?.[review.reviewer]) { note(`${id}: reviewer "${review.reviewer}" 가 physicians 에 없음 (건너뜀)`); }
else if (data.reviewer !== review.reviewer || data.reviewedAt !== review.reviewedAt || data.reviewStatus !== 'reviewed') {
data.reviewer = review.reviewer; data.reviewedAt = review.reviewedAt; data.reviewStatus = 'reviewed'; data.dateModified = today;
data.history = [...(data.history ?? []), { date: review.reviewedAt, note: `담당 원장 의학 검토 완료 (${authors.physicians[review.reviewer].name})` }];
changed = true; note(`${id}: 의학 검토 ${review.reviewer} ${review.reviewedAt}`);
}
}
if (approval?.approvedBy && approval?.approvedAt) {
if (data.reviewStatus !== 'reviewed') note(`${id}: 의학 검토 전이라 게시 승인 보류`);
else if (data.approvedAt !== approval.approvedAt || data.approvedBy !== approval.approvedBy) {
data.approvedBy = approval.approvedBy; data.approvedAt = approval.approvedAt; data.dateModified = today;
data.history = [...(data.history ?? []), { date: approval.approvedAt, note: `게시 승인 (${approval.approvedBy})` }];
changed = true; note(`${id}: 게시 승인 ${approval.approvedBy} ${approval.approvedAt}`);
}
}
if (changed && !dryRun) writeFileSync(join(POSTS, f), `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${body.trim()}\n`);
}
// ---------- 저장 ----------
if (!dryRun) {
writeFileSync(join(DATA, 'authors.json'), JSON.stringify(authors, null, 2) + '\n');
writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n');
writeFileSync(join(DATA, 'corrections.json'), JSON.stringify(corrections, null, 2) + '\n');
writeFileSync(join(DATA, 'clinicAnswers.json'), JSON.stringify(answers, null, 2) + '\n');
}
console.log(`입력 ${rows.length}행 · 유효 ${latest.size}건 · 반영 ${changes.length}건${dryRun ? ' (dry-run)' : ''}`);
for (const c of changes) console.log(' ·', c);

View File

@ -4,6 +4,7 @@
// node scripts/check.mjs 검사
// node scripts/check.mjs --update-layout 구조 기준선(§3-2) 갱신. 룩앤필 변경을 haewon이 승인한 뒤에만
// node scripts/check.mjs --today=YYYY-MM-DD
// node scripts/check.mjs --home-text=../evidence/<clinic>/home_text.txt 40자 중복 검사 기준을 수집기 산출물로
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -17,10 +18,13 @@ 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 homeTextPath = args.find((a) => a.startsWith('--home-text='))?.slice(12) ?? here('./home_text.txt');
const HOME_TEXT = existsSync(homeTextPath) ? readFileSync(homeTextPath, '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 site = JSON.parse(readFileSync(here('../src/data/site.json'), 'utf8'));
const INDEXABLE = process.env.PUBLIC_INDEXABLE === 'true';
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
// 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙).
@ -63,6 +67,14 @@ for (const f of files) {
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
add(rel, R.checkOperatorVocab(text, rel));
add(rel, R.checkHeadingEndings(html));
// §12-2 (1) 글 첫 부분 지원 고지. site.sponsorNotice 가 켜져 있으면 error, 아니면 warn
if (rel.startsWith('/posts/') && rel !== '/posts.html') add(rel, R.checkTopDisclosure(html, { required: Boolean(site.sponsorNotice) }));
// §12-2 (2) 색인 허용 빌드에 미승인 글이 섞이면 error
if (INDEXABLE && rel.startsWith('/posts/') && rel !== '/posts.html') {
const slug = rel.replace(/^\/posts\//, '').replace(/\.html$/, '');
const src = join(POSTS, `${slug}.md`);
if (existsSync(src) && !R.parseMarkdown(readFileSync(src, 'utf8')).data.approvedAt) add(rel, [{ level: R.E, code: 'UNAPPROVED_IN_INDEXABLE_BUILD', msg: '색인 허용 빌드인데 게시 승인(approvedAt) 없는 글이 포함됨' }]);
}
// 구조 지문
if (LAYOUT_PAGES[rel]) layoutNow[rel] = R.layoutFingerprint(html);
}

View File

@ -0,0 +1,296 @@
// 근거 수집기 (NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §7-3, §4-1).
// 병원 홈페이지의 원문 페이지(주의사항·애프터케어·검진·안전·시술 상세·의료진·오시는길)를 curl+파서로 받아
// evidence/<clinic>/ 에 구조화해 저장한다. 요약 도구를 쓰지 않는다(§3-10). 없는 것은 없다고 남긴다.
//
// node scripts/collect_evidence.mjs --clinic viewclinic --url https://www.viewclinic.com [--out ../evidence] [--max 80] [--delay 400]
//
// 산출물
// evidence/<clinic>/index.json 페이지 목록(유형·글자 수·표·이미지·플래그)
// evidence/<clinic>/pages/<slug>.json 페이지별 원문(제목·헤딩·섹션·표·목록·이미지·링크)
// evidence/<clinic>/doctors.json 의료진 페이지에서 읽은 이름·직함·약력(이미지 글자면 textInImages=true)
// evidence/<clinic>/facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기")
// evidence/<clinic>/home_text.txt 전 페이지 본문 공백 제거본. 발행 게이트의 40자 연속 일치 검사 기준
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'node-html-parser';
// ---------- 인자 ----------
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; };
const CLINIC = arg('--clinic'); const START = arg('--url');
if (!CLINIC || !START) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const OUT = join(arg('--out', fileURLToPath(new URL('../../evidence/', import.meta.url))), CLINIC);
const MAX = Number(arg('--max', 250)); const DELAY = Number(arg('--delay', 400));
const UA = 'Mozilla/5.0 (compatible; INFINITH-evidence/0.1; +https://infinith-demo.vercel.app)';
const today = new Date().toISOString().slice(0, 10);
// ---------- 페이지 유형 ----------
// 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다.
const TYPES = [
['precautions', /precaution|caution|주의사항|주의\s*사항/i],
['aftercare', /after-?care|사후관리|애프터/i],
['checkup', /examination|check-?up|검진/i],
['safety', /\bsafe|안전/i],
['doctor', /\bdoctors?\b|의료진|\bstaff\b|physician/i],
['direction', /\bdirections?\d*\b|\blocation\b|오시는\s*길|찾아오|\bcontact\b|\bway-?to/i],
['facilities', /facilit|\btour\b|둘러보|시설/i],
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting/i],
['reservation', /\breserv|\bcounsel|예약|상담/i],
['pricing', /\bprice|\bcost\b|비급여|수가|비용/i],
];
const SKIP = /\/(board|news|event|community|review|gallery|model|login|register|mypage|cart|feed|tag|category|author|wp-|xmlrpc|\?s=)|before-?and-?after|before-?after|\/(before|after)\/|\.(jpg|jpeg|png|gif|svg|pdf|css|js|ico|zip)(\?|$)|#|mailto:|tel:|javascript:/i;
const PROCEDURE_HINT = /수술\s*시간|마취|회복|실밥|입원|붓기|절개|시술\s*시간|수술\s*방법/;
// 경로를 먼저 보고, 경로로 못 정하면 짧은 앵커 텍스트(메뉴 항목)만 본다. 메뉴 전체가 붙은 긴 앵커는 무시.
function classify(path, anchor = '') {
const segs = path.split('/').filter(Boolean).map((x) => x.replace(/[-_]/g, ' '));
for (const [t, re] of TYPES) if (segs.some((x) => re.test(x)) || re.test(path)) return t;
if (anchor && anchor.length <= 30) for (const [t, re] of TYPES) if (re.test(anchor)) return t;
return 'candidate';
}
// ---------- fetch ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchText(url, tries = 2) {
for (let i = 0; i <= tries; i++) {
try {
const res = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(25000) });
const body = await res.text();
return { status: res.status, finalUrl: res.url, body, contentType: res.headers.get('content-type') ?? '' };
} catch (e) { if (i === tries) return { status: 0, finalUrl: url, body: '', error: e.message }; await sleep(800 * (i + 1)); }
}
}
// ---------- robots ----------
async function loadRobots(origin) {
const r = await fetchText(`${origin}/robots.txt`, 0);
const dis = [];
if (r.status === 200) {
let star = false;
for (const line of r.body.split('\n')) {
const l = line.trim();
if (/^user-agent:/i.test(l)) star = /user-agent:\s*\*/i.test(l);
else if (star && /^disallow:/i.test(l)) { const p = l.replace(/^disallow:\s*/i, '').trim(); if (p) dis.push(p); }
}
}
return { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
}
// ---------- 링크 정규화 ----------
function normalize(href, base) {
try {
const u = new URL(href, base);
if (!/^https?:$/.test(u.protocol)) return null;
u.hash = ''; u.search = '';
let s = u.toString();
if (!/\.[a-z0-9]{2,5}$/i.test(u.pathname) && !s.endsWith('/')) s += '/';
return s;
} catch { return null; }
}
const sameHost = (u, origin) => new URL(u).host.replace(/^www\./, '') === new URL(origin).host.replace(/^www\./, '');
// ---------- 본문 추출 ----------
const MAIN_SELECTORS = ['main', '#ajax-content-wrap', '#main-content', '#content', '#contents', '#container', '.container-wrap', '#sub_content', '.sub_content', '.sub-content', '#subContent', 'article', '#wrap', '#wrapper'];
const NOISE_TAGS = 'script,style,noscript,iframe,form,svg,template,video,audio,object,embed';
const NOISE_SELECTORS = 'header,footer,nav,[id*="menu"],[class*="menu"],[id*="popup"],[class*="popup"],[id*="modal"],[class*="modal"],[class*="share"],[class*="quick"],[class*="floating"],[class*="breadcrumb"],[class*="cookie"],[class*="sns"],[id*="search"],[class*="widget"],[class*="wpcf7"],[class*="eform"],[class*="offcanvas"],[class*="gnb"],[class*="lnb"],[id*="gnb"],[id*="lnb"],[class*="topbar"],[class*="top-bar"]';
const BLOCK = new Set(['div', 'section', 'article', 'main', 'aside', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figure', 'figcaption', 'blockquote', 'dl', 'dt', 'dd', 'pre', 'address']);
const clean = (s) => s.replace(/ /g, ' ').replace(/[ \t\r\f\v]+/g, ' ').replace(/\s*\n\s*/g, ' ').trim();
function hasBlockDescendant(el) {
return el.querySelectorAll('*').some((c) => BLOCK.has(c.rawTagName?.toLowerCase()));
}
/** DOM → 순서 있는 항목 목록 [{kind:'h'|'p'|'li'|'table', level?, text?, rows?}] */
function walk(el, items) {
const tag = el.rawTagName?.toLowerCase();
if (!tag) return;
if (/^h[1-6]$/.test(tag)) { const t = clean(el.text); if (t) items.push({ kind: 'h', level: Number(tag[1]), text: t }); return; }
if (tag === 'table') {
const rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text))).filter((r) => r.some(Boolean));
if (rows.length) items.push({ kind: 'table', rows });
return;
}
if (tag === 'li') { if (!hasBlockDescendant(el)) { const t = clean(el.text); if (t) items.push({ kind: 'li', text: t }); return; } }
if (BLOCK.has(tag) && !hasBlockDescendant(el)) { const t = clean(el.text); if (t) items.push({ kind: 'p', text: t }); return; }
for (const c of el.childNodes) {
if (c.nodeType === 1) walk(c, items);
else if (c.nodeType === 3) { const t = clean(c.rawText ?? c.text ?? ''); if (t && t.length > 1 && BLOCK.has(tag)) items.push({ kind: 'p', text: t }); }
}
}
function extract(html, url) {
const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true, pre: true } });
const title = clean(root.querySelector('title')?.text ?? '');
const metaDesc = root.querySelector('meta[name="description"]')?.getAttribute('content') ?? '';
root.querySelectorAll(NOISE_TAGS).forEach((e) => e.remove());
const body = root.querySelector('body') ?? root;
// 링크 발견용 앵커는 메뉴를 지우기 전에 전체 body에서 모은다 (메뉴가 곧 사이트 구조다)
const anchors = body.querySelectorAll('a[href]').map((a) => [normalize(a.getAttribute('href'), url), clean(a.text)]).filter(([u]) => u);
// 본문 컨테이너: 후보 중 텍스트가 200자를 넘는 첫 것. 없으면(이미지 위주 페이지) 텍스트가 가장 많은 후보, 그것도 없으면 body
let main = null;
for (const sel of MAIN_SELECTORS) { const el = body.querySelector(sel); if (el && clean(el.text).length > 200) { main = el; break; } }
if (!main) { const found = MAIN_SELECTORS.map((sel) => body.querySelector(sel)).filter(Boolean); if (found.length) main = found.sort((a, b) => clean(b.text).length - clean(a.text).length)[0]; }
main = main ?? body;
const mainSelector = main === body ? 'body' : MAIN_SELECTORS.find((s) => body.querySelector(s) === main);
main.querySelectorAll(NOISE_SELECTORS).forEach((e) => e.remove());
const items = []; walk(main, items);
const images = main.querySelectorAll('img').map((i) => ({ src: normalize(i.getAttribute('data-src') || i.getAttribute('src') || '', url), alt: clean(i.getAttribute('alt') ?? '') })).filter((i) => i.src && !/\.(svg|gif)$/i.test(i.src) && !/icon|logo|btn|button|arrow|blank|spacer|sns|share|1x1/i.test(i.src));
const links = [...new Set(main.querySelectorAll('a[href]').map((a) => normalize(a.getAttribute('href'), url)).filter(Boolean))];
return { title, metaDesc, mainSelector, items, images, links, anchors };
}
/** 항목을 헤딩 기준 섹션으로 묶는다 */
function sectionize(items) {
const sections = []; let cur = { heading: null, level: 0, paragraphs: [], lists: [], tables: [] };
for (const it of items) {
if (it.kind === 'h') { if (cur.heading || cur.paragraphs.length || cur.lists.length || cur.tables.length) sections.push(cur); cur = { heading: it.text, level: it.level, paragraphs: [], lists: [], tables: [] }; }
else if (it.kind === 'p') cur.paragraphs.push(it.text);
else if (it.kind === 'li') cur.lists.push(it.text);
else if (it.kind === 'table') cur.tables.push(it.rows);
}
if (cur.heading || cur.paragraphs.length || cur.lists.length || cur.tables.length) sections.push(cur);
return sections;
}
// ---------- 사실 후보(정규식) ----------
function factCandidates(text) {
const pick = (re) => [...new Set([...text.matchAll(re)].map((m) => clean(m[1] ?? m[0])))];
return {
phone: pick(/(?<!\d)(0\d{1,2}[-.\s]\d{3,4}[-.\s]\d{4})(?!\d)/g),
address: pick(/((?:서울|경기|인천|부산|대구|대전|광주|울산|세종|강원|충북|충남|전북|전남|경북|경남|제주)[^\n|·]{6,60}?(?:빌딩|건물|층|호|\d+번지|로\s?\d+|길\s?\d+))/g),
businessNo: pick(/(?<!\d)(\d{3}-\d{2}-\d{5})(?!\d)/g),
representative: pick(/대표(?:자|원장|의사)?\s*[::]?\s*([가-힣]{2,4})(?=\s|$|[,·|])/g),
hours: pick(/((?:월|화|수|목|금|토|일|평일|주말|공휴일)[^\n|]{0,20}?\d{1,2}[:시]\d{0,2}\s*[~\-–]\s*\d{1,2}[:시]\d{0,2})/g),
founded: pick(/((?:19|20)\d{2})\s*년\s*(?:개원|설립|오픈)/g),
};
}
// ---------- 의료진 ----------
function parseDoctor(page) {
const text = [page.title, page.h1, ...page.items.filter((i) => i.kind !== 'table').map((i) => i.text)].join('\n');
const name = text.match(/([가-힣]{2,4})\s*(?:대표\s*)?원장/)?.[1] ?? null;
const specialty = text.match(/(성형외과|이비인후과|마취통증의학과|피부과|영상의학과|외과|치과|내과)\s*전문의/)?.[0] ?? null;
const isRep = /대표\s*원장/.test(text);
const credentials = page.items.filter((i) => i.kind === 'li' || (i.kind === 'p' && /대학|병원|학회|전문의|회원|교수|수료|연수|졸업|위촉|박사|석사/.test(i.text) && i.text.length < 80)).map((i) => i.text).filter((t) => !/원장$/.test(t));
const creds = [...new Set(credentials)].filter((c) => c !== specialty);
const status = !name ? '이름 미확인 · 확인 대기' : creds.length < 2 ? (page.flags.textInImages ? '약력 이미지 글자 · 확인 대기' : '약력 본문 없음 · 확인 대기') : 'ok';
return { id: page.slug.replace(/^.*doctors?-?/, ''), url: page.url, name, title: [isRep ? '대표원장' : '원장', specialty].filter(Boolean).join(' · ') || null, credentials: creds, images: page.images.slice(0, 5), textInImages: page.flags.textInImages, status };
}
// ---------- 메인 ----------
const origin = new URL(START).origin;
mkdirSync(join(OUT, 'pages'), { recursive: true });
const robots = await loadRobots(origin);
console.log(`[1/4] 시작 ${START} (robots Disallow ${robots.disallow.length}개)`);
// 링크 발견: 홈 + 사이트맵
const home = await fetchText(START);
if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); }
const homeEx = extract(home.body, home.finalUrl);
const queue = new Map(); // url → { anchor, fromHome }
const addLink = (u, anchor = '', fromHome = false) => { if (!u || !sameHost(u, origin) || SKIP.test(u) || /\.xml$/i.test(u) || !robots.allowed(new URL(u).pathname)) return; const cur = queue.get(u); if (!cur) queue.set(u, { anchor, fromHome }); else if (fromHome && !cur.fromHome) cur.fromHome = true; };
for (const [u, a] of homeEx.anchors) addLink(u, a, true);
for (const sm of ['/sitemap.xml', '/wp-sitemap.xml', '/sitemap_index.xml', '/page-sitemap.xml']) {
const r = await fetchText(`${origin}${sm}`, 0);
if (r.status !== 200 || !/<(urlset|sitemapindex)/.test(r.body)) continue;
const locs = [...r.body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
const subs = locs.filter((l) => /sitemap.*\.xml$/i.test(l));
const pageSubs = subs.filter((l) => /page/i.test(l)); // 글·영상(post)은 뺀다. 원문 페이지만
for (const l of pageSubs) { const sub = await fetchText(l, 0); for (const m of sub.body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) addLink(normalize(m[1], origin)); }
if (!subs.length) for (const l of locs) addLink(normalize(l, origin));
console.log(` 사이트맵 ${sm}: 링크 누적 ${queue.size}`);
break;
}
console.log(`[2/4] 후보 링크 ${queue.size}개 → 유형 분류`);
// 유형 분류 후 우선순위대로 MAX까지
const ranked = [...queue.entries()].map(([u, { anchor, fromHome }]) => ({ url: u, anchor, fromHome, depth: new URL(u).pathname.split('/').filter(Boolean).length, type: classify(new URL(u).pathname, anchor) }));
const prio = (t) => { const i = TYPES.findIndex(([k]) => k === t); return i === -1 ? 99 : i; };
// 유형이 잡힌 페이지 먼저(의료진 상세는 40명까지), 그다음 후보는 홈 메뉴에서 연결된 것 → 얕은 경로 순
const isDoctorDetail = (x) => x.type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(x.url).pathname);
const typed = ranked.filter((x) => x.type !== 'candidate').sort((a, b) => prio(a.type) - prio(b.type) || a.depth - b.depth);
const doctorDetails = typed.filter(isDoctorDetail).slice(0, 40); const typedRest = typed.filter((x) => !isDoctorDetail(x));
const cands = ranked.filter((x) => x.type === 'candidate').sort((a, b) => Number(b.fromHome) - Number(a.fromHome) || a.depth - b.depth || a.url.length - b.url.length || a.url.localeCompare(b.url));
const targets = [...typedRest, ...doctorDetails, ...cands].slice(0, MAX);
console.log(` 유형 확정 ${typedRest.length} · 의료진 상세 ${doctorDetails.length} · 후보 ${cands.length} → 수집 ${targets.length}`);
// 수집
const pages = [];
const slugOf = (u) => (new URL(u).pathname.replace(/^\/|\/$/g, '').replace(/[^a-zA-Z0-9가-힣]+/g, '-') || 'home');
async function collect(t) {
const r = await fetchText(t.url);
if (r.status !== 200 || !/html/.test(r.contentType)) { pages.push({ url: t.url, slug: slugOf(t.url), type: t.type, status: r.status, error: r.error ?? 'non-html', items: [], images: [], links: [], flags: {} }); return; }
const ex = extract(r.body, r.finalUrl);
const h1 = ex.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
let type = t.type;
const bodyText = ex.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n');
if (type === 'candidate') type = PROCEDURE_HINT.test(bodyText) ? 'procedure' : 'other';
if (type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(t.url).pathname) && !/\/doctors?\/?$/.test(new URL(t.url).pathname)) type = 'doctor-detail';
pages.push({ url: r.finalUrl, requestedUrl: t.url, slug: slugOf(r.finalUrl), type, anchor: t.anchor, status: r.status, title: ex.title, metaDesc: ex.metaDesc, h1, mainSelector: ex.mainSelector, items: ex.items, images: ex.images, links: ex.links.filter((l) => sameHost(l, origin)), rawText: bodyText, fullTextForFacts: clean(parse(r.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
}
{
let i = 0; const workers = Array.from({ length: 3 }, async () => { while (i < targets.length) { const t = targets[i++]; await collect(t); process.stdout.write(`\r 수집 ${pages.length}/${targets.length}`); await sleep(DELAY); } });
await Promise.all(workers);
}
// 홈도 페이지로
{
const h1 = homeEx.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
pages.unshift({ url: home.finalUrl, requestedUrl: START, slug: 'home', type: 'home', anchor: '', status: 200, title: homeEx.title, metaDesc: homeEx.metaDesc, h1, mainSelector: homeEx.mainSelector, items: homeEx.items, images: homeEx.images, links: homeEx.links.filter((l) => sameHost(l, origin)), rawText: homeEx.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n'), fullTextForFacts: clean(parse(home.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
}
console.log(`\n[3/4] 페이지 ${pages.length}개 수집. 반복 줄 제거`);
// 반복 줄(boilerplate) 제거: 페이지의 30% 이상(최소 3)에 같은 줄이 있으면 메뉴·푸터로 본다
const ok = pages.filter((p) => p.status === 200 && !p.error);
const freq = new Map();
for (const p of ok) for (const t of new Set(p.items.filter((i) => i.kind !== 'table').map((i) => i.text))) freq.set(t, (freq.get(t) ?? 0) + 1);
const threshold = Math.max(3, Math.ceil(ok.length * 0.3));
const boiler = new Set([...freq.entries()].filter(([, n]) => n >= threshold).map(([t]) => t));
for (const p of ok) {
const before = p.items.length;
p.items = p.items.filter((i) => i.kind === 'table' || !boiler.has(i.text));
p.images = p.images.filter((im) => !ok.filter((q) => q !== p).some((q) => q.images.some((x) => x.src === im.src)) || ok.length < 3);
p.sections = sectionize(p.items);
p.text = p.items.map((i) => i.kind === 'table' ? i.rows.map((r) => r.join(' | ')).join('\n') : i.text).join('\n');
p.chars = p.text.replace(/\s+/g, '').length;
p.flags = { textInImages: p.chars < 500 && p.images.length >= 3, noText: p.chars < 100, boilerplateRemoved: before - p.items.length, tables: p.items.filter((i) => i.kind === 'table').length };
}
// 저장
const index = [];
for (const p of pages) {
const file = `pages/${p.slug}.json`;
const { fullTextForFacts, rawText, ...save } = p;
writeFileSync(join(OUT, file), JSON.stringify({ clinic: CLINIC, fetchedAt: today, ...save }, null, 1));
index.push({ file, url: p.url, type: p.type, status: p.status, mainSelector: p.mainSelector ?? null, title: p.title ?? null, h1: p.h1 ?? null, chars: p.chars ?? 0, tables: p.flags?.tables ?? 0, images: p.images?.length ?? 0, flags: Object.entries(p.flags ?? {}).filter(([k, v]) => v === true).map(([k]) => k) });
}
const byType = index.reduce((m, x) => ((m[x.type] = (m[x.type] ?? 0) + 1), m), {});
writeFileSync(join(OUT, 'index.json'), JSON.stringify({ clinic: CLINIC, start: START, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1));
// 의료진
const doctors = ok.filter((p) => p.type === 'doctor-detail').map(parseDoctor);
writeFileSync(join(OUT, 'doctors.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: 'textInImages=true 인 약력은 이미지 글자라 본문에 없다. authors.json에 옮기기 전에 사람이 확인한다.', doctors }, null, 1));
// 사실 후보
const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type));
const facts = {};
for (const p of factSrc) { const c = factCandidates(p.fullTextForFacts); for (const [k, vals] of Object.entries(c)) for (const val of vals) { facts[k] ??= {}; facts[k][val] ??= []; if (!facts[k][val].includes(p.url)) facts[k][val].push(p.url); } }
writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기".', candidates: facts }, null, 1));
// 40자 중복 검사 기준 텍스트
writeFileSync(join(OUT, 'home_text.txt'), ok.map((p) => p.text.replace(/\s+/g, '')).join('\n'));
// 인용 URL 커버리지: --posts <dir> 의 글 frontmatter에서 type: clinic 소스 URL을 뽑아 수집 여부를 본다 (정답지 회귀)
const postsDir = arg('--posts');
if (postsDir && existsSync(postsDir)) {
const { readdirSync } = await import('node:fs');
const cited = new Set();
for (const f of readdirSync(postsDir).filter((x) => x.endsWith('.md'))) for (const m of readFileSync(join(postsDir, f), 'utf8').matchAll(/url:\s*"([^"]+)",\s*accessed:[^}]*type:\s*clinic/g)) if (sameHost(m[1], origin)) cited.add(normalize(m[1], origin));
const have = new Set(pages.flatMap((p) => [p.url, p.requestedUrl].filter(Boolean)));
const missing = [];
for (const u of cited) { if (have.has(u)) continue; const r = await fetchText(u, 0); if (!have.has(normalize(r.finalUrl, origin))) missing.push(u); }
console.log(` 인용 URL 커버리지 ${cited.size - missing.length}/${cited.size}${missing.length ? ' · 누락: ' + missing.join(', ') : ''}`);
}
console.log(`[4/4] 저장 ${OUT}`);
console.log(` 유형별: ${Object.entries(byType).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
console.log(` 의료진 ${doctors.length}명 (약력 확인 대기 ${doctors.filter((d) => d.status !== 'ok').length}명) · 반복 줄 ${boiler.size}개 제거 · home_text ${ok.reduce((n, p) => n + p.chars, 0).toLocaleString()}자`);
const bad = index.filter((x) => x.status !== 200); if (bad.length) console.log(` 실패 ${bad.length}: ${bad.map((b) => `${b.status} ${b.url}`).join(', ')}`);

View File

@ -0,0 +1,78 @@
// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11).
// 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다.
// 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다.
//
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6]
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
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 clinic = opt('clinic'); const EV = resolve(opt('evidence', `../evidence/${clinic}`)); const SITE = resolve(opt('site', '.'));
const max = Number(opt('max', '6'));
if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic 과 --evidence <dir> (index.json 있는 폴더) 필요'); process.exit(2); }
const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
const fact = JSON.parse(readFileSync(join(SITE, 'src/data/factSheet.json'), 'utf8'));
const authors = JSON.parse(readFileSync(join(SITE, 'src/data/authors.json'), 'utf8'));
const name = fact.shortName || fact.name || clinic;
const pages = index.pages.filter((p) => p.status === 200 && p.chars >= 200);
const byType = (t) => pages.filter((p) => p.type === t).sort((a, b) => b.chars - a.chars);
const urls = (...types) => types.flatMap((t) => byType(t).slice(0, 3).map((p) => p.url));
const physicianIds = Object.keys(authors.physicians ?? {});
const rep = physicianIds.find((id) => /대표/.test(authors.physicians[id].title ?? '')) ?? physicianIds[0];
const candidates = [
{
id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'],
title: `${name}은 어떤 병원인가요? 위치·진료 분야·의료진 구성`,
intent: '병원 소개·진료과·의료진 수·건물·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장 수).',
need: ['about', 'doctor', 'direction'], pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep,
},
{
id: 'visit-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-01', 'B-03', 'B-05'],
title: `${name} 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?`,
intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길 페이지만 근거로. 확인되지 않은 시간은 "전화 확인".',
need: ['direction'], pages: urls('direction', 'reservation'), reviewer: null,
},
{
id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'],
title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`,
intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
need: ['safety'], pages: urls('safety', 'checkup'), reviewer: physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep,
},
{
id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'],
title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?',
intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
need: ['precautions'], pages: urls('precautions', 'aftercare'), reviewer: rep,
},
{
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.',
need: ['safety', 'aftercare'], pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep,
},
{
id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'],
title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?',
intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".',
need: ['aftercare'], pages: urls('aftercare', 'checkup'), reviewer: rep,
},
];
const posts = candidates
.filter((c) => c.need.every((t) => byType(t).length) && c.pages.length)
.slice(0, max)
.map(({ need, pages: pg, reviewer, ...c }) => ({
...c,
avoid: ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담'],
reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: [], gallery: [],
}));
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', posts };
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));
writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n');
console.log(`brief ${posts.length}편 → ${outPath}${posts.length < candidates.length ? ` (근거 부족으로 제외 ${candidates.length - posts.length}편: ${candidates.filter((c) => !posts.find((p) => p.id === c.id)).map((c) => c.id).join(', ')})` : ''}`);

View File

@ -0,0 +1,12 @@
---
expect: [APPROVED_WITHOUT_BY, APPROVED_BEFORE_REVIEW]
approvedAt: "2026-09-07"
reviewStatus: pending
---
## 게시 승인 필드 검사
이 픽스처는 승인자 없이, 의학 검토 전에 게시 승인이 찍힌 경우입니다. 상담에서 확인하세요.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |

View File

@ -0,0 +1,8 @@
<!-- expect: COMPARISON_CLAIM -->
<html><body>
<h1>안전 체계는 어떻게 되어 있나요?</h1>
<table><tr><th>항목</th><th>안내</th></tr>
<tr><td>마취과 전문의 상주</td><td>병원급 이상 평균 23.1%, 뷰성형외과 43% 상주</td></tr>
</table>
<p>다른 병원에서 한 수술도 재수술 상담이 됩니다. 상담에서 확인하세요.</p>
</body></html>

View File

@ -112,7 +112,7 @@ export function checkSourcesType(data) {
}
/** FDA·ISO·승인·연구 주장이 있으면 regulation 타입 원문 소스가 있어야 한다. 학회명(연구회·연구소)은 제외. */
export const REGULATION_CLAIM = /FDA|ISO\s?\d|승인|연구(?!회|소|원|팀|실)/;
export const REGULATION_CLAIM = /FDA|ISO\s?\d|(?<!게시\s?)승인(?!자|일)|연구(?!회|소|원|팀|실)/; // "게시 승인"(§12-2 개설자 승인)은 규제 주장이 아니다
export function checkRegulationSource(data, fields) {
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
if (hasReg) return [];
@ -214,9 +214,30 @@ export function checkReview(data, physicians = {}, supporters = {}) {
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 없음'));
}
// 게시 승인(v2 §12-2): 승인일이 있으면 승인자(개설자·직함)도 있어야 하고, 의학 검토가 끝난 글만 승인할 수 있다.
if (data.approvedAt && !data.approvedBy) out.push(r(E, 'APPROVED_WITHOUT_BY', 'approvedAt 이 있는데 approvedBy(승인자) 없음'));
if (data.approvedBy && !data.approvedAt) out.push(r(E, 'APPROVED_WITHOUT_DATE', 'approvedBy 가 있는데 approvedAt 없음'));
if (data.approvedAt && data.reviewStatus !== 'reviewed') out.push(r(E, 'APPROVED_BEFORE_REVIEW', '의학 검토(reviewed) 전에 게시 승인됨'));
return out;
}
// ---------- §12-2 (3) 기자·전문가 화법 (의료법 56조 2항 10호 대응). 경고 수준 ----------
export const PRESS_VOICE = /본지|기자(?!회견)|전문가에 따르면|업계에서는|업계 관계자/;
export function checkPressVoice(text, field = '') {
const m = text.match(PRESS_VOICE);
return m ? [r(W, 'PRESS_VOICE', `${field ? field + ': ' : ''}기자·전문가 화법: "${m[0]}"`)] : [];
}
// ---------- §12-2 (1) 지원 관계 고지의 상단 배치 ----------
/** 글 페이지 HTML 에서 상단 고지(.disclosure-top)가 세 줄 요약(.summary) 앞에 있어야 한다. notice 가 비어 있으면(아직 미승인) 경고만. */
export function checkTopDisclosure(html, { required = false } = {}) {
const top = html.search(/class="[^"]*disclosure-top[^"]*"/);
const summary = html.search(/class="[^"]*\bsummary\b[^"]*"/);
if (top >= 0 && (summary < 0 || top < summary)) return [];
const level = required ? E : W;
return [r(level, 'DISCLOSURE_TOP_MISSING', top < 0 ? '글 첫 부분에 지원 관계 고지 없음 (site.sponsorNotice 를 채우면 렌더됨)' : '지원 관계 고지가 세 줄 요약보다 아래에 있음')];
}
// ---------- §3-8 싣지 않는 것 ----------
export const BANNED_VIDEO_TITLE = /전후|리뷰|성공|번호따|변신|역대급/;
export function checkVideoTitles(list, { level = E, label = '' } = {}) {
@ -227,11 +248,25 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/완치/, /100\s*%/, /부작용\s*(이|은)?\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]}"`));
return [
...BANNED_BODY.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'BANNED_PHRASE', `금칙 표현: "${m[0]}"`)),
...checkComparison(text),
];
}
/** 타 병원·업계 평균과의 수치 비교 (§3-8 비교 광고, 의료법 56조 2항 4호). "다른 병원에서 한 수술" 같은 서술은 수치가 없어 통과. */
const CMP_SUBJECT = '(병원급|의원급|업계|타\\s?병원|다른\\s?병원|타원|경쟁\\s?병원)';
export const COMPARISON_CLAIM = [
new RegExp(`${CMP_SUBJECT}[^.\\n]{0,30}(평균|대비|보다)[^.\\n]{0,30}\\d+(\\.\\d+)?\\s?%`),
new RegExp(`\\d+(\\.\\d+)?\\s?%[^.\\n]{0,30}${CMP_SUBJECT}`),
/평균\s*\d+(\.\d+)?\s?%[^.\n]{0,30}(상주|보유|운영)/,
];
export function checkComparison(text) {
return COMPARISON_CLAIM.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'COMPARISON_CLAIM', `타 병원·업계 비교 수치: "${m[0].slice(0, 70)}"`));
}
// ---------- §3-11 원천 콘텐츠 ----------
@ -266,5 +301,6 @@ export function checkPostSource({ data, body }, { physicians, supporters } = {})
...checkVideoTitles([...(data.videos ?? []), ...(data.video ? [data.video] : [])], { label: 'frontmatter' }),
...checkOriginalContent(data, body),
...fields.flatMap((f) => checkPlainEndings(f.text, f.field)),
...fields.flatMap((f) => checkPressVoice(f.text, f.field)),
];
}

View File

@ -63,5 +63,31 @@ const negatives = [
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
// 5) 생성기 직렬화 왕복: 정답지 frontmatter → js-yaml dump → 다시 파싱해도 값이 같고, 소스 게이트(금칙어·비교 수치·홈페이지 중복 포함) error 0
{
const { toMarkdown, gatePost } = await import('../gen/serialize.mjs');
const homeText = readFileSync(here('../home_text.txt'), 'utf8');
const KEYS = ['title', 'summary', 'description', 'faq', 'sources', 'videos', 'qbIds', 'tags', 'hero', 'thumbnail', 'reviewer', 'reviewedAt', 'datePublished'];
let rt = 0;
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const g = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const md = toMarkdown(g.data, g.body);
const m = R.parseMarkdown(md);
for (const k of KEYS) if (JSON.stringify(g.data[k] ?? null) !== JSON.stringify(m.data[k] ?? null)) { rt++; bad(`왕복 ${f}`, `${k} 값이 바뀜`); }
if (typeof m.data.datePublished !== 'string') { rt++; bad(`왕복 ${f}`, 'datePublished 가 문자열이 아님 (YAML 날짜 자동 변환)'); }
const errs = gatePost(md, { ...ctx, homeText }).errors;
if (errs.length) { rt++; errs.forEach((e) => bad(`생성기 게이트 ${f}`, `[${e.code}] ${e.msg}`)); }
}
if (!rt) ok('생성기 직렬화 왕복·소스 게이트', '18편 값 동일, error 0');
}
// 6) 수치 대조·비교 수치 (생성기 전용 규칙)
{
const cmp = R.checkComparison('마취과 전문의 상주 비율은 병원급 이상 평균 23.1%인데 뷰성형외과는 43%입니다.');
cmp.length ? ok('비교 수치 검출', cmp[0].code) : bad('비교 수치 검출', '병원급 평균 23.1% vs 43% 를 못 잡음');
const okCmp = R.checkComparison('다른 병원에서 한 수술도 재수술 상담이 되나요? 상담에서 확인하세요.');
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
}
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);

View File

@ -0,0 +1,149 @@
// 글 생성기의 근거 컨텍스트 조립. v2 §4 근거 프로토콜의 순서를 그대로 입력 구조로 만든다.
// 1 병원 공식 페이지 원문 (evidence/<clinic>/pages) → [C1..]
// 2 쇼츠 정리 답 (videos.json.shortsInfo[].answer) → [S1..]
// (선택) 자막 전사 brief.evidence.transcripts → [T1..]
// 3 원장 기고·인터뷰 기사 (news.json, 제목·매체·날짜만) → [N1..]
// 4 규제·연구 원문 (brief.evidence.regulation) → [R1..]
// 5 플랫폼 집계·팩트 시트 (factSheet.json) → [F]
// 없는 근거는 missing 으로 돌려주고, 모델에게는 "없다"고 알린다. 사실을 만들어 채우지 않는다.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
export function normUrl(u) {
try {
const x = new URL(u);
let s = x.host.replace(/^www\./, '') + x.pathname;
try { s = decodeURIComponent(s); } catch { /* 그대로 */ }
return s.replace(/\/+$/, '').toLowerCase();
} catch { return String(u).replace(/\/+$/, '').toLowerCase(); }
}
export function loadEvidence(evDir) {
const index = JSON.parse(readFileSync(join(evDir, 'index.json'), 'utf8'));
const byUrl = new Map();
const pages = [];
const pagesDir = join(evDir, 'pages');
for (const f of readdirSync(pagesDir).filter((x) => x.endsWith('.json'))) {
const p = JSON.parse(readFileSync(join(pagesDir, f), 'utf8'));
pages.push(p);
for (const u of [p.url, p.requestedUrl]) if (u) byUrl.set(normUrl(u), p);
}
const doctorsPath = join(evDir, 'doctors.json');
const doctors = existsSync(doctorsPath) ? JSON.parse(readFileSync(doctorsPath, 'utf8')).doctors ?? [] : [];
const homePath = join(evDir, 'home_text.txt');
const homeText = existsSync(homePath) ? readFileSync(homePath, 'utf8') : '';
return { clinic: index.clinic, start: index.start, fetchedAt: index.fetchedAt, pages, byUrl, doctors, homeText };
}
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. */
export function pageToText(p, { maxChars = 9000 } = {}) {
const lines = [];
for (const it of p.items ?? []) {
if (!it?.text && it?.kind !== 'table') continue;
if (it.kind === 'h') lines.push(`${'#'.repeat(Math.min(Math.max(it.level ?? 2, 2), 4))} ${it.text}`);
else if (it.kind === 'li') lines.push(`- ${it.text}`);
else if (it.kind === 'table') for (const row of it.rows ?? []) lines.push('| ' + row.map((c) => String(c).replace(/\|/g, '/')).join(' | ') + ' |');
else lines.push(it.text);
}
let t = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
if (t.length > maxChars) t = t.slice(0, maxChars) + `\n…(이 페이지는 ${t.length - maxChars}자가 더 있으나 생략됨. 생략된 부분의 사실은 쓰지 않는다)`;
return t;
}
const SOURCE_TYPE_LABEL = { clinic: '병원 제공', doctor: '원장 설명', product: '제품 정보', regulation: '규제·연구', platform: '플랫폼 집계', secondary: '2차 자료' };
/**
* brief 하나에 대한 컨텍스트 문자열과 sources 배열을 만든다.
* brief.evidence = { pages: [url], shorts: [videoId], transcripts: [{id,title,speaker,text}], news: [url], regulation: [{label,url,note}], platform: bool }
*/
export function buildContext(brief, { ev, fact, authors, videos, news, answers, today }) {
const parts = [];
const missing = [];
const sources = [];
const evd = brief.evidence ?? {};
const clinicName = fact?.shortName ?? fact?.name ?? ev.clinic;
const seenSrc = new Set();
const pushSource = (s) => { const k = normUrl(s.url); if (seenSrc.has(k)) return; seenSrc.add(k); sources.push(s); };
// 1. 병원 공식 페이지
const cparts = [];
(evd.pages ?? []).forEach((url, i) => {
const p = ev.byUrl.get(normUrl(url));
if (!p) { missing.push(`병원 페이지가 근거 수집 결과에 없음: ${url}`); return; }
const imgText = Array.isArray(p.flags) ? p.flags.includes('textInImages') : Boolean(p.flags?.textInImages);
if (!p.items?.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자)' : ''}: ${url}`); return; }
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}\n\n${pageToText(p)}`);
pushSource({ label: `${clinicName} 홈페이지 · ${cleanTitle(p.title, clinicName)}`, url: p.url, accessed: p.fetchedAt ?? today, type: 'clinic' });
});
parts.push(`## 1. 병원 공식 페이지 원문 (문장을 그대로 옮기지 말 것. 40자 이상 연속 일치는 발행 차단)\n\n${cparts.length ? cparts.join('\n\n') : '(제공된 페이지 없음)'}`);
// 2. 쇼츠 정리 답 + 자막
const sparts = [];
(evd.shorts ?? []).forEach((id) => {
const s = (videos?.shortsInfo ?? []).find((x) => x.id === id);
if (!s?.answer) { missing.push(`쇼츠 정리 답 없음: ${id}`); return; }
sparts.push(`### [S${sparts.length + 1}] 영상 「${s.title}」 (게시 ${s.published ?? '?'}, 조회수 ${fmtNum(s.views)} · 확인 ${videos.fetchedAt})\n편집자가 정리한 한 문단(이 문단의 범위 안에서만 영상 내용을 쓴다):\n${s.answer}`);
pushSource({ label: `유튜브 · ${s.title}`, url: `https://www.youtube.com/watch?v=${s.id}`, accessed: videos.fetchedAt ?? today, type: 'doctor' });
});
(evd.transcripts ?? []).forEach((t) => {
if (!t?.text) return;
sparts.push(`### [T${sparts.length + 1}] 영상 「${t.title ?? t.id}」 자막 전사${t.speaker ? ` (화자: ${t.speaker})` : ''}\n${String(t.text).slice(0, 12000)}`);
pushSource({ label: `유튜브 · ${t.title ?? t.id}${t.speaker ? ` (${t.speaker})` : ''}`, url: `https://www.youtube.com/watch?v=${t.id}`, accessed: today, type: 'doctor' });
});
parts.push(`## 2. 원장 영상 근거 (여기 없는 발언은 만들지 않는다)\n\n${sparts.length ? sparts.join('\n\n') : '(영상 근거 없음. 영상 발언을 인용하지 않는다)'}`);
// 3. 기사
const nparts = [];
(evd.news ?? []).forEach((url) => {
const n = (news?.items ?? []).find((x) => normUrl(x.url) === normUrl(url));
if (!n) { missing.push(`뉴스 항목 없음: ${url}`); return; }
nparts.push(`### [N${nparts.length + 1}] ${n.outlet} · ${n.date} · 「${n.title}」 (${n.kind === 'release' ? '병원 보도자료' : n.kind === 'column' ? '원장 기고·인터뷰' : '언론 언급'})\n본문은 제공되지 않는다. 제목·매체·날짜 범위에서만 언급할 수 있다.`);
pushSource({ label: `${n.outlet} · ${n.title} (${n.date})`, url: n.url, accessed: today, type: n.kind === 'release' ? 'clinic' : n.kind === 'column' ? 'doctor' : 'secondary' });
});
parts.push(`## 3. 기사\n\n${nparts.length ? nparts.join('\n\n') : '(기사 근거 없음)'}`);
// 4. 규제·연구 원문
const rparts = [];
(evd.regulation ?? []).forEach((r) => {
if (!r?.url) return;
rparts.push(`### [R${rparts.length + 1}] ${r.label}\nURL: ${r.url}\n확인된 내용: ${r.note ?? '요약 없음. 이 자료의 수치·결론을 인용하지 말고, 「원문에서 확인」으로 링크만 안내한다.'}`);
pushSource({ label: r.label, url: r.url, accessed: r.accessed ?? today, type: 'regulation' });
});
parts.push(`## 4. 규제·연구 원문\n\n${rparts.length ? rparts.join('\n\n') : '(규제·연구 근거 없음. FDA·ISO·승인·연구를 언급하지 않는다)'}`);
// 4b. 병원이 서면으로 확인해 준 답변 (apply_inputs → clinicAnswers.json). §6 4~7항. 병원 제공 근거로 친다.
const aparts = (answers?.items ?? []).map((a, i) => `### [A${i + 1}] ${a.question ?? a.key} (병원 확인 ${a.at}${a.by ? `, ${a.by}` : ''})\n${a.answer}`);
if (aparts.length) {
parts.push(`## 4b. 병원 확인 답변 (병원이 서면으로 확인한 값. "병원 확인 기준"으로 인용한다)\n\n${aparts.join('\n\n')}`);
pushSource({ label: `${clinicName} 서면 확인 답변 (${(answers.items.at(-1)?.at) ?? today})`, url: fact?.url ?? '', accessed: today, type: 'clinic' });
}
// 5. 팩트 시트 (플랫폼 집계 포함)
if (fact) {
const slim = { ...fact };
delete slim._comment;
parts.push(`## 5. 병원 팩트 시트 (확인일 ${fact.checkedAt ?? '?'}, 다음 확인 ${fact.nextCheck ?? '?'}). 플랫폼 집계 수치를 쓰면 같은 문장에 확인일을 붙인다\n\n${JSON.stringify(slim, null, 1).slice(0, 7000)}`);
const surf = fact.surfaces ?? {};
for (const [k, s] of Object.entries(surf)) if (s?.url && evd.platform) pushSource({ label: `${s.label ?? k} · ${clinicName}`, url: s.url, accessed: s.checkedAt ?? fact.checkedAt ?? today, type: 'platform' });
}
// 6. 의료진 (이름·직함만. 약력은 authors.json 에 있는 것만)
const phys = Object.entries(authors?.physicians ?? {}).map(([id, p]) => `- ${id}: ${p.name} · ${p.title}${p.specialty ? ` · ${p.specialty}` : ''}`);
parts.push(`## 6. 의료진 (이 목록 밖의 원장 이름을 쓰지 않는다)\n\n${phys.join('\n') || '(등록된 의료진 없음)'}`);
// 7. 관련 영상 목록 (제목·화자만)
const vids = (brief.videos ?? []).map((v) => `- ${v.id} 「${v.title}」${v.speaker ? ` · ${v.speaker}` : ''}${v.published ? ` · ${v.published}` : ''}${v.note ? ` · ${v.note}` : ''}`);
parts.push(`## 7. 글에 붙는 영상 목록 (제목·화자만 안다. 내용은 2절에 있는 것만 쓴다)\n\n${vids.join('\n') || '(없음)'}`);
// brief 가 sources 를 직접 주면 그것을 우선(정답지 회귀용). type 없는 항목은 규칙상 실패이므로 secondary 로 채우고 경고.
const finalSources = brief.sources?.length
? brief.sources.map((s) => ({ ...s, accessed: s.accessed ?? today, type: s.type ?? 'secondary' }))
: sources;
return { text: parts.join('\n\n'), sources: finalSources, missing, typeLabel: SOURCE_TYPE_LABEL };
}
function cleanTitle(t, clinicName) {
return String(t ?? '').replace(new RegExp(`\\s*\\|\\s*${clinicName}.*$`), '').replace(/\s*\|\s*뷰성형외과.*$/, '').trim() || '페이지';
}
function fmtNum(n) { return typeof n === 'number' ? n.toLocaleString('ko-KR') : String(n ?? '?'); }

View File

@ -0,0 +1,151 @@
// 글 생성 프롬프트와 출력 스키마. 규칙의 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3·§4·§12-2.
// 여기 적힌 규칙은 대부분 scripts/gate/rules.mjs 와 generate_posts.mjs 의 수치 대조·검증 단계가 다시 검사한다. 프롬프트는 1차 방어, 게이트가 최종.
export const SYSTEM_PROMPT = `당신은 성형외과 서포터즈 매체의 편집자입니다. 병원이 지원하는 매체이므로 광고로 읽히지 않으려면 사실만, 근거가 있는 것만 씁니다.
독자는 상담을 앞둔 사람입니다. 독자가 상담에서 무엇을 묻고 무엇을 확인해야 하는지 알게 하는 것이 글의 목적입니다.
# 사실의 출처 (이 순서로만, 밖의 사실은 쓰지 않는다)
1. 병원 공식 페이지 원문 [C]. 문장을 옮기되 40자 이상 연속으로 같으면 안 됩니다. 반드시 다시 씁니다.
2. 원장 영상 근거 [S]/[T]. 정리 답 한 문단의 범위 안에서만 씁니다. 자막이 없으면 원장 발언을 만들지 않습니다. 화자는 "OO 원장이 영상에서 설명합니다"로 밝힙니다.
3. 기사 [N]. 제목·매체·날짜만 압니다. 본문 내용을 추정해 쓰지 않습니다.
4. 규제·연구 원문 [R]. "확인된 내용"에 적힌 범위에서만 인용합니다. 요약이 없으면 수치를 쓰지 않고 링크로만 안내합니다. [R]이 하나도 없으면 FDA·ISO·승인·연구라는 말을 쓰지 않습니다.
5. 팩트 시트 [F]. 플랫폼 집계 수치(후기 수·평점 등)를 쓰면 같은 문장에 확인일을 붙이고, 글 안에서 같은 수치는 항상 같은 확인일로 씁니다.
수치 규칙. 근거 문장에 있는 수치를 있는 그대로 씁니다. 범위(3~7일)를 바꾸거나(5~7일), 단위를 바꾸거나, 근거의 수치를 근거에 없는 행위에 붙이지 않습니다(예: 근거에 "운동은 2개월 후"만 있으면 "걷기는 1개월 후"라고 쓰지 않습니다). 근거가 어떤 조건(절개 방식·부위)에 한정한 수치는 그 조건을 같이 씁니다.
근거에 없는 것은 없다고 씁니다. "병원 공개 자료에 없습니다. 상담에서 확인하세요." 또는 "병원 확인 대기"로 표기합니다. 페이지 간 표기가 다르면 둘 다 적고 다르다고 밝힙니다.
# 글 구조
- 세 줄 요약(summary) 3개. 각 1~3문장. 질문에 대한 직접 답. 첫 줄에 핵심 답, 둘째 줄에 조건·예외, 셋째 줄에 독자가 할 일. 수치가 들어간 줄에는 같은 줄 안에 "주의사항 기준", "홈페이지 안내 기준", "영상 사례", "개인차" 중 하나를 씁니다.
- 본문(body)은 마크다운. H1(#)은 쓰지 않습니다(제목은 별도). H2(##)는 질문형 또는 명사구. "~합니다"로 끝나는 제목은 금지.
- 본문 첫 H2 앞에 도입 문단 2~3문장: 이 글이 무엇을 어떤 자료로 정리했는지.
- 표(마크다운 table)를 1개 이상 넣습니다. 회복 일정·마취 선택·검진 주기·비교처럼 조건이 붙는 답은 반드시 표로 씁니다. 표 바로 아래 문단에 표의 한계(개인차, 병원 확인 대기)를 씁니다. 근거에 없는 칸은 "확인"이라고 씁니다.
- 체크리스트("- [ ] 항목")는 기획 의도에 체크리스트가 있거나 분류가 C·F 일 때만 씁니다.
- 글 끝은 "## 상담에서 확인할 것" 아래 목록 3개로 닫습니다.
- 본문에 쓰지 않는 것: FAQ 섹션(faq 필드가 화면에 따로 붙습니다), 참고 자료 목록, 병원 연락처·예약 안내 블록, 작성자·검토자 표기, 갱신 기록(전부 템플릿이 자동으로 붙입니다). 근거 태그([C1], [S2] 같은 표기)는 본문·요약·FAQ·description 어디에도 쓰지 않고 usedRefs 에만 적습니다.
- FAQ 5~8개. 질문은 독자 말투, 답은 2~4문장. 본문과 같은 근거만. 수치가 있는 답에는 "개인차" 또는 "상담에서 확인"을 같은 답 안에 씁니다.
- 태그 3~6개(명사).
- description 은 90~160자. 글이 무엇을 어떤 근거로 정리했는지.
- 분량: 본문 1,200~2,200자(한글 기준). 근거가 적으면 짧게. 근거 없이 늘리지 않습니다.
# 화법
- 존댓말 "~입니다/~합니다"만. 반말 서술("~한다", "~있다")은 금지. 개조식 명사 종결("~없음")은 표 안에서만.
- 엠대시(—)와 은유·비유 금지. 직설 문장. 한 문장에 한 가지.
- 기자·전문가 화법 금지: "본지", "기자", "전문가에 따르면", "업계에서는".
- 병원 이름은 팩트 시트의 shortName 으로.
# 넣지 않는 것 (발행 차단)
- 전후 사진, 환자 후기·경험담 인용, 효과·안전 보장, 가격 금액(항목과 확인처만), "완치", "100%", "부작용 없음", "최고의 병원".
- 다른 병원·업계 평균과의 비교. 근거 페이지에 "병원급 평균 23.1% vs 43%" 같은 비교 수치가 있어도 옮기지 않습니다(비교 광고). 병원 자체의 사실(마취과 전문의 상주)만 씁니다.
- 운영자 어휘: 답변엔진, AI 인용, AEO, GEO, 질문 뱅크, 실측, 단일 원본, 수집 기준, 임베드, 롱폼, 쇼츠 분류, 의료법 제N조.
- 결론 단정: "안정성이 확인", "안전이 보장", "안전하다고 볼 수 있", "장점을 합친", "비교우위"(원장 의견으로 표기하면 가능), "다음 날 출근할 수 있을 정도".
# 근거 범위 표현
- 제품 허가는 "특정 제품·해당 국가 적응증"으로 한정하고 "위험이 없다는 뜻은 아닙니다"를 붙입니다.
- 원장의 제품 선호·비교는 "원장의 임상 의견"으로 표기합니다. 특정 제품 권유는 "진료에서 정합니다"로 닫습니다.
- 수술 시간·회복·복귀 시점 같은 수치(분·일·주·개월)가 있는 문단에는 반드시 "개인차", "상담에서 확인", "영상 사례", "주의사항 기준", "홈페이지 안내" 중 하나를 같은 문단에 씁니다.
# 출력
JSON 스키마대로만 출력합니다. pending 에는 독자가 물었을 법한데 근거에 없어 쓰지 못한 사실을 한 줄씩 적습니다(병원 확인 요청 목록이 됩니다). usedRefs 에는 실제로 인용한 근거 태그(C1, S2 형식)만 적습니다.`;
export function userPrompt({ brief, context, fact, authorName, reviewerName, today }) {
const cat = `${brief.category} ${brief.categoryLabel ?? ''}`.trim();
return `# 이번 글
- 제목(H1, 질문형): ${brief.title}
- 분류: ${cat} · 문항 ID: ${(brief.qbIds ?? []).join(', ') || '(없음)'}
- 작성자: ${authorName ?? '확인 대기'} · 검토 예정 원장: ${reviewerName ?? '미정 (의학 검토 대기)'}
- 오늘: ${today}
${brief.intent ? `- 기획 의도: ${brief.intent}\n` : ''}${brief.mustCover?.length ? `- 반드시 다룰 것: ${brief.mustCover.join(' / ')}\n` : ''}${brief.avoid?.length ? `- 이 글에서 피할 것: ${brief.avoid.join(' / ')}\n` : ''}
# 근거
${context}
# 작성 지시
위 근거만으로 "${brief.title}"에 답하는 글을 JSON 스키마대로 작성하세요. 근거에 없는 사실은 pending 에 적고 본문에는 "병원 공개 자료에 없습니다"로 씁니다. 병원 이름은 "${fact?.shortName ?? ''}"으로 씁니다.`;
}
export function repairPrompt(errors, warns) {
const e = errors.map((x) => `- [${x.code}] ${x.msg}`).join('\n');
const w = warns.slice(0, 12).map((x) => `- [${x.code}] ${x.msg}`).join('\n');
return `발행 게이트에서 다음 오류가 나왔습니다. 오류 항목만 고치고 나머지 내용·구조는 유지한 채 같은 JSON 스키마로 전체를 다시 출력하세요. 사실을 새로 추가하지 마세요.
## 오류 (반드시 고칠 것)
${e || '(없음)'}
## 경고 (가능하면 고칠 것)
${w || '(없음)'}
고치는 방법:
- UNSUPPORTED_CLAIM / NUMBER_NOT_IN_EVIDENCE: 해당 문장을 삭제하거나 "병원 공개 자료에 없습니다. 상담에서 확인하세요."로 바꿉니다. 수치는 근거 문장 그대로만 씁니다.
- HOME_DUPLICATE: 해당 구절을 다른 어순·어휘로 다시 씁니다.
- CONCLUSION_PHRASE: 단정 표현을 삭제하거나 "원장의 임상 의견입니다"로 한정합니다.
- COMPARISON_CLAIM: 다른 병원·업계 평균과 비교하는 수치를 삭제합니다. 병원 자체 사실만 남깁니다.
- REGULATION_SOURCE_MISSING: FDA·ISO·승인·연구 언급을 삭제합니다(근거 자료가 없습니다).
- NUMBER_DATE_MISMATCH: 같은 수치에는 같은 확인일을 씁니다.
- BANNED_PHRASE / OPERATOR_VOCAB: 해당 표현을 삭제합니다.
- NUMERIC_NO_QUALIFIER: 수치가 있는 문단·요약 줄·FAQ 답에 "개인차가 있습니다" 또는 "상담에서 확인하세요"를 붙입니다.
- PLAIN_ENDING: 반말 문장을 "~입니다/~합니다"로 바꿉니다.
- HEADING_VERB: 제목을 명사구 또는 질문형으로 바꿉니다.
- BODY_H1: 본문의 # 제목을 ## 으로 바꿉니다.`;
}
/** OpenAI structured output 스키마 (strict). */
export const OUTPUT_SCHEMA = {
name: 'supporters_post',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'array', items: { type: 'string' }, description: '세 줄 요약 3개' },
description: { type: 'string' },
body: { type: 'string', description: '마크다운 본문. H1 없음, FAQ 없음, 근거 태그 없음' },
faq: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' }, a: { type: 'string' } }, required: ['q', 'a'] } },
tags: { type: 'array', items: { type: 'string' } },
pending: { type: 'array', items: { type: 'string' }, description: '근거에 없어 쓰지 못한 사실. 병원 확인 요청 목록' },
usedRefs: { type: 'array', items: { type: 'string' }, description: '실제 인용한 근거 태그 (C1, S2 형식)' },
},
required: ['summary', 'description', 'body', 'faq', 'tags', 'pending', 'usedRefs'],
},
};
// ---------- 근거 대조 검증 (2차 호출) ----------
export const VERIFY_SYSTEM = `당신은 사실 확인 편집자입니다. 글의 모든 사실 주장을 아래 근거와 대조합니다.
찾을 것:
1. unsupported: 근거 어디에도 없는 사실 주장. 특히 수치(시간·기간·비율·개수), 시점, 행위("걷기는 1개월 후"처럼 근거에 없는 행위에 수치를 붙인 것), 제품·장비·프로그램 이름, 원장 발언, 병원 정책.
2. distorted: 근거에 있지만 다르게 옮긴 것. 범위가 바뀜(3~7일 → 5~7일), 조건이 빠짐(특정 절개법 제외 조건 누락), 다른 부위의 수치를 이 부위에 씀, 한 사례를 일반화.
검사 대상이 아닌 것: "병원 공개 자료에 없습니다", "상담에서 확인하세요", "개인차가 있습니다" 같은 안내 문장, 독자에게 권하는 행동(메모해 가세요), 표 구조 자체, 일반 상식 수준의 설명이 아닌 것만 골라내되 의학적 일반론("부기는 시간이 지나며 빠집니다")은 unsupported 로 보지 않습니다.
근거에 있는 것을 unsupported 라고 하지 않도록 근거를 끝까지 읽습니다. 확신이 없으면 넣지 않습니다. claim 에는 글의 문장을 그대로 짧게 인용합니다.
각 항목에 supported 를 적습니다. 검토해 보니 근거에 있는 주장이면 supported=true (이 항목은 무시됩니다), 근거에 없거나 다르게 옮긴 것만 supported=false. reason 에 "명시되어 있다", "근거에 있다"라고 쓰게 되는 항목은 supported=true 입니다.`;
export function verifyPrompt({ context, post }) {
return `# 근거
${context}
# 검사할 글
## 요약
${post.summary.map((s) => `- ${s}`).join('\n')}
## 본문
${post.body}
## FAQ
${post.faq.map((f) => `Q. ${f.q}\nA. ${f.a}`).join('\n\n')}
# 지시
위 글에서 근거에 없는 주장(unsupported)과 근거와 다르게 옮긴 문장(distorted)을 JSON 스키마대로 모두 나열하세요. 없으면 빈 배열.`;
}
export const VERIFY_SCHEMA = {
name: 'claim_verification',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
unsupported: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'reason', 'supported'] } },
distorted: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, evidence: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'evidence', 'reason', 'supported'] } },
},
required: ['unsupported', 'distorted'],
},
};

View File

@ -0,0 +1,64 @@
// 모델 출력(JSON) → 글 파일(md) 직렬화와, 빌드 없이 도는 소스 수준 게이트.
// frontmatter 는 js-yaml dump 로만 만든다(v2 §8: 큰따옴표 속 큰따옴표·날짜 문자열 함정을 라이브러리가 처리).
import yaml from 'js-yaml';
import * as R from '../gate/rules.mjs';
export function toMarkdown(data, body) {
const fm = yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false, sortKeys: false });
return `---\n${fm}---\n${String(body).trim()}\n`;
}
/**
* 글 한 편의 소스 수준 게이트. dist 없이 frontmatter·본문만 본다.
* 반환 { errors, warns, stats }. errors 가 있으면 발행 불가.
*/
export function gatePost(md, { physicians = {}, supporters = {}, homeText = '' } = {}) {
const parsed = R.parseMarkdown(md);
const { data, body } = parsed;
const findings = [...R.checkPostSource(parsed, { physicians, supporters })];
const fields = R.postTextFields(data, body);
const fullText = [data.title ?? '', ...fields.map((f) => f.text)].join('\n');
findings.push(...R.checkBannedBody(fullText));
findings.push(...R.checkOperatorVocab(fullText));
findings.push(...R.checkHomeDuplicate(fullText, homeText));
// 마크다운 제목 화법 (dist 에서는 h1~h3 로 검사하는 것을 소스에서 미리)
for (const m of body.matchAll(/^(#{1,3})\s+(.+?)\s*$/gm)) {
if (m[1] === '#') findings.push({ level: R.E, code: 'BODY_H1', msg: `본문에 H1: "${m[2].slice(0, 40)}" (제목은 frontmatter title 하나)` });
if (R.HEADING_VERB_END.test(m[2])) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `제목이 동사 종결 (명사구로): "${m[2]}"` });
}
if (R.HEADING_VERB_END.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `title 이 동사 종결: "${data.title}"` });
// 구조 규격
const summary = data.summary ?? [];
if (summary.length < 2 || summary.length > 4) findings.push({ level: R.E, code: 'SUMMARY_COUNT', msg: `세 줄 요약이 ${summary.length}개 (2~4개)` });
const faq = data.faq ?? [];
if (faq.length < 5) findings.push({ level: R.W, code: 'FAQ_FEW', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
if (faq.length > 8) findings.push({ level: R.W, code: 'FAQ_MANY', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
// 표 수 = 헤더 구분선(|---|) 수
const tableCount = (body.match(/^\|\s*:?-+/gm) ?? []).length;
if (!tableCount) findings.push({ level: R.W, code: 'NO_TABLE', msg: '본문에 표가 없음 (1개 이상 권장)' });
if (!/\?\s*$/.test(String(data.title ?? '')) && !/(표|목록|체크리스트|기록)\s*$/.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'TITLE_NOT_QUESTION', msg: `제목이 질문형이 아님: "${data.title}"` });
const desc = String(data.description ?? '');
if (desc.length < 60 || desc.length > 200) findings.push({ level: R.W, code: 'DESCRIPTION_LENGTH', msg: `description ${desc.length}자 (90~160자 권장)` });
const stats = {
hangul: (body.match(/[가-힣]/g) ?? []).length,
bodyChars: body.length,
tables: tableCount,
checklist: (body.match(/^\s*- \[ \]/gm) ?? []).length,
h2: (body.match(/^##\s/gm) ?? []).length,
faq: faq.length,
summary: summary.length,
sources: (data.sources ?? []).length,
sourceTypes: [...new Set((data.sources ?? []).map((s) => s.type).filter(Boolean))],
};
return {
errors: findings.filter((x) => x.level === R.E),
warns: findings.filter((x) => x.level === R.W),
stats,
data,
body,
};
}

View File

@ -0,0 +1,359 @@
// 글 생성기 (v2 §7-4). 기획 목록(brief) + 근거(evidence) → GPT-4 계열 → frontmatter 규격 md + 소스 게이트 + 근거 대조 + 비용 리포트.
//
// node scripts/generate_posts.mjs --clinic viewclinic --from-golden 정답지 18편의 문항·근거로 재생성 (회귀)
// node scripts/generate_posts.mjs --clinic viewclinic --brief briefs/x.json 기획 목록으로 생성
// 옵션: --only a,b --limit N --model gpt-4.1 --out DIR --dry-run(프롬프트만 저장) --mock(정답지를 모델 출력으로 대체, API 없이 파이프라인 검증)
// --no-verify(근거 대조 2차 호출 생략) --verify-model gpt-4.1 --warn-threshold 4(경고가 이 수 이상이면 수정 호출)
// --max-cost-usd 3 --price-in 2 --price-out 8 (USD / 1M tokens) --today YYYY-MM-DD --temperature 0.3
//
// 흐름: 컨텍스트(§4 순서) → 생성 → 후처리(근거 태그·중복 섹션 제거) → 소스 게이트 + 수치 대조 → 근거 대조(모델) → 오류면 수정 1회 → 저장 → 리포트
// 원칙: sources·videos·검토자 후보는 brief 에서 결정론적으로 조립하고 모델은 요약·본문·FAQ·태그만 쓴다.
// 검토 상태는 항상 pending (§3-7 사람 게이트). reviewedAt 은 생성기가 절대 쓰지 않는다.
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
import { join, dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as R from './gate/rules.mjs';
import { loadEvidence, buildContext } from './gen/evidence.mjs';
import { SYSTEM_PROMPT, userPrompt, repairPrompt, OUTPUT_SCHEMA, VERIFY_SYSTEM, verifyPrompt, VERIFY_SCHEMA } from './gen/prompt.mjs';
import { toMarkdown, gatePost } from './gen/serialize.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); if (i >= 0 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; const eq = args.find((a) => a.startsWith(`--${k}=`)); return eq ? eq.slice(k.length + 3) : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic', 'viewclinic');
const today = opt('today', new Date().toISOString().slice(0, 10));
const model = opt('model', 'gpt-4.1');
const verifyModel = opt('verify-model', model);
const temperature = Number(opt('temperature', '0.3'));
const limit = Number(opt('limit', '0')) || Infinity;
const only = opt('only', '') ? opt('only').split(',').map((s) => s.trim()).filter(Boolean) : null;
const dryRun = flag('dry-run');
const mock = flag('mock');
const doVerify = !flag('no-verify') && !mock;
const warnThreshold = Number(opt('warn-threshold', '4'));
const maxRepairs = Number(opt('max-repairs', '2'));
const maxCostUsd = Number(opt('max-cost-usd', '0')) || Infinity;
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).replace('T', '-');
const OUT = resolve(opt('out', join(SUP, 'generated', clinic, stamp)));
// 공개 가격표 (USD / 1M tokens, 2025 목록가). 실제 청구와 다를 수 있다. --price-in/--price-out 으로 덮어쓴다.
const PRICES = { 'gpt-4.1': [2, 8], 'gpt-4.1-mini': [0.4, 1.6], 'gpt-4.1-nano': [0.1, 0.4], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6] };
const priceOf = (m) => PRICES[m] ?? [Number(opt('price-in', 0)), Number(opt('price-out', 0))];
const priceIn = Number(opt('price-in', priceOf(model)[0]));
const priceOut = Number(opt('price-out', priceOf(model)[1]));
// ---------- .env (저장소 루트에서 위로 탐색) ----------
function loadEnv() {
let dir = SUP;
for (let i = 0; i < 4; i++) {
const p = join(dir, '.env');
if (existsSync(p)) {
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, '');
}
}
dir = dirname(dir);
}
}
loadEnv();
// ---------- 데이터 ----------
// --site <dir> : 다른 사이트 디렉터리(템플릿 복제본)의 데이터·글을 쓴다. 워커가 병원별 작업 폴더로 돌릴 때.
const SITE = resolve(opt('site', SUP));
const DATA = join(SITE, 'src/data');
const POSTS = join(SITE, 'src/content/posts');
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const fact = readJson(join(DATA, 'factSheet.json'));
const authors = readJson(join(DATA, 'authors.json'));
const videos = readJson(join(DATA, 'videos.json'));
const news = existsSync(join(DATA, 'news.json')) ? readJson(join(DATA, 'news.json')) : { items: [] };
const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson(join(DATA, 'clinicAnswers.json')) : { items: [] };
if (!existsSync(join(EV, 'index.json'))) { console.error(`근거 없음: ${EV}. 먼저 node scripts/collect_evidence.mjs --clinic ${clinic} --url <홈>`); process.exit(1); }
const ev = loadEvidence(EV);
const authorId = opt('author', Object.keys(authors.supporters ?? {})[0]);
const clinicHost = (() => { try { return new URL(fact.url).host.replace(/^www\./, ''); } catch { return ''; } })();
const CATEGORY_LABEL = { A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·마취', F: '비용·이벤트', G: '후기·평판' };
// ---------- brief ----------
function briefsFromGolden() {
const shortsIds = new Set((videos.shortsInfo ?? []).map((s) => s.id));
return readdirSync(POSTS).filter((f) => f.endsWith('.md')).sort().map((f) => {
const { data } = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const srcs = data.sources ?? [];
const isClinic = (u) => { try { return new URL(u).host.replace(/^www\./, '') === clinicHost; } catch { return false; } };
const isYt = (u) => /youtube\.com|youtu\.be/.test(u);
const vids = [...(data.videos ?? []), ...(data.video ? [data.video] : [])];
return {
id: basename(f, '.md'),
title: data.title, category: data.category, categoryLabel: data.categoryLabel, qbIds: data.qbIds ?? [],
reviewerCandidate: data.reviewer,
sources: srcs,
videos: data.videos ?? [], hero: data.hero, gallery: data.gallery ?? [], thumbnail: data.thumbnail,
evidence: {
pages: srcs.filter((s) => isClinic(s.url)).map((s) => s.url),
shorts: [...new Set(vids.map((v) => v.id).filter((id) => shortsIds.has(id)))],
news: srcs.filter((s) => !isClinic(s.url) && !isYt(s.url) && ['clinic', 'doctor', 'secondary'].includes(s.type)).map((s) => s.url),
regulation: srcs.filter((s) => s.type === 'regulation').map((s) => ({ label: s.label, url: s.url })),
platform: srcs.some((s) => s.type === 'platform'),
},
golden: f,
};
});
}
function briefsFromFile(p) { const j = readJson(resolve(p)); return Array.isArray(j) ? j : j.posts ?? []; }
let briefs = flag('from-golden') ? briefsFromGolden() : opt('brief') ? briefsFromFile(opt('brief')) : null;
if (!briefs) { console.error('--from-golden 또는 --brief <file> 이 필요합니다'); process.exit(1); }
if (only) briefs = briefs.filter((b) => only.includes(b.id));
briefs = briefs.slice(0, limit);
// ---------- 모델 호출 ----------
async function callModel(messages, { schema = OUTPUT_SCHEMA, useModel = model, temp = temperature } = {}) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY 없음 (.env)');
const body = { model: useModel, temperature: temp, messages, response_format: { type: 'json_schema', json_schema: schema } };
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body) });
if (res.status === 429 || res.status >= 500) { lastErr = new Error(`${res.status} ${await res.text()}`); await new Promise((r) => setTimeout(r, 4000 * (attempt + 1))); continue; }
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const j = await res.json();
const msg = j.choices?.[0]?.message;
if (msg?.refusal) throw new Error(`모델 거부: ${msg.refusal}`);
return { out: JSON.parse(msg.content), raw: msg.content, usage: j.usage ?? {}, model: useModel };
}
throw lastErr;
}
const costOf = (u, m) => { const [i, o] = m === model ? [priceIn, priceOut] : priceOf(m); return ((u.prompt_tokens ?? 0) * i + (u.completion_tokens ?? 0) * o) / 1e6; };
// 단가 상한 정책 (v2 §9-5 미결). 지금은 누적 비용이 --max-cost-usd 를 넘으면 남은 글을 건너뛴다.
function shouldStop({ spentUsd }) { return spentUsd >= maxCostUsd; }
// ---------- 후처리 ----------
const REF_TAG = /\s*\[(?:[CSTNRF]\d*)\](?:\s*\[(?:[CSTNRF]\d*)\])*/g;
const DROP_SECTIONS = /^#{2,3}\s*(FAQ|자주 묻는 질문|자주 하는 질문|참고 자료|참고 링크|참고|상담 예약 및 문의|상담 예약|문의|병원 정보|병원 연락처|작성·검토|갱신 기록)[^\n]*\n[\s\S]*?(?=^##\s|(?![\s\S]))/gm;
function postProcess(out) {
const strip = (s) => String(s ?? '').replace(REF_TAG, '').replace(/[ \t]+$/gm, '');
return {
...out,
summary: (out.summary ?? []).map(strip).filter(Boolean),
description: strip(out.description),
faq: (out.faq ?? []).map((f) => ({ q: strip(f.q), a: strip(f.a) })),
body: strip(out.body).replace(DROP_SECTIONS, '').replace(/\n{3,}/g, '\n\n').trim(),
tags: (out.tags ?? []).map((t) => String(t).trim()).filter(Boolean),
pending: (out.pending ?? []).map((t) => String(t).trim()).filter(Boolean),
usedRefs: (out.usedRefs ?? []).map((x) => String(x).replace(/[\[\]]/g, '').trim()).filter(Boolean),
};
}
// ---------- 수치 대조 (결정론) ----------
// 글의 "숫자+단위" 토큰이 근거 컨텍스트에 그대로 있어야 한다. 범위(3~7일)는 통째로, 없으면 양 끝(3일·7일)이 각각 있으면 경고.
const NUM = '(\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)';
const NUM_UNIT = new RegExp(`(?<![\\d,.])${NUM}(?:\\s*[~\\-–〜]\\s*${NUM})?\\s*(분|일|주|개월|달|년|%|회|cc|㎛|mm|명|층|개|건|점|kg|시간)`, 'g');
const COUNT_UNIT = /^(명|개|건|점|회|층)$/; // 팩트 시트 JSON 에는 단위 없이 숫자만 있을 수 있다
const normNum = (s) => String(s).replace(/\s+/g, '').replace(/[–〜\-]/g, '~').replace(/(\d)달/g, '$1개월').replace(/(\d)(?:,(\d{3}))+/g, (m) => m.replace(/,/g, ''));
function checkNumbersGrounded(texts, contextText) {
const ctx = normNum(contextText);
const bare = (n) => new RegExp(`(?<![\\d.])${normNum(n)}(?![\\d.])`).test(ctx);
const out = [];
const seen = new Set();
for (const { field, text } of texts) {
for (const m of String(text).matchAll(NUM_UNIT)) {
const [raw, a, b, unit] = m;
const before = text.slice(Math.max(0, m.index - 1), m.index);
if (unit === '년' && Number(a) >= 1900) continue; // 연도
if (/월/.test(before) && unit === '일') continue; // 9월 4일
if (unit === '일' && /^\d{4}-\d{2}-/.test(text.slice(Math.max(0, m.index - 8), m.index))) continue;
const token = normNum(raw);
if (seen.has(token)) continue;
seen.add(token);
if (ctx.includes(token)) continue;
if (!b && COUNT_UNIT.test(unit) && bare(a)) continue; // "901건" vs 팩트 시트의 901
if (b && ctx.includes(normNum(a + unit)) && ctx.includes(normNum(b + unit))) { out.push({ level: R.W, code: 'RANGE_RECOMBINED', msg: `${field}: 범위 "${raw}" 는 근거에 통째로 없고 양 끝만 있음` }); continue; }
out.push({ level: R.E, code: 'NUMBER_NOT_IN_EVIDENCE', msg: `${field}: 근거에 없는 수치 "${raw}"` });
}
}
return out;
}
// ---------- frontmatter 조립 ----------
function assemble(brief, out, sources, pending) {
const reviewer = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate] ? brief.reviewerCandidate : undefined;
const note = `자동 생성 초안 (${mock ? '정답지 대체' : model}). 의학 검토 대기${pending.length ? `. 병원 확인 대기 ${pending.length}건` : ''}`;
const data = {
title: brief.title,
category: brief.category,
categoryLabel: brief.categoryLabel ?? CATEGORY_LABEL[brief.category] ?? '',
qbIds: brief.qbIds ?? [],
summary: out.summary,
description: out.description,
author: authorId,
...(reviewer ? { reviewer } : {}),
reviewStatus: 'pending',
datePublished: today,
dateModified: today,
...(brief.hero ? { hero: brief.hero } : {}),
faq: out.faq,
sources,
tags: out.tags,
history: [{ date: today, note }],
videos: brief.videos ?? [],
gallery: brief.gallery ?? [],
...(brief.thumbnail ? { thumbnail: brief.thumbnail } : {}),
};
let body = String(out.body).trim();
if (pending.length) body += `\n\n## 병원 확인 대기\n\n이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.\n\n${pending.map((p) => `- ${p}`).join('\n')}`;
return { data, body };
}
function goldenOutput(brief) {
const { data, body } = R.parseMarkdown(readFileSync(join(POSTS, brief.golden), 'utf8'));
return { summary: data.summary, description: data.description, body, faq: data.faq ?? [], tags: data.tags ?? [], pending: [], usedRefs: [] };
}
/** 한 편을 검사한다: 소스 게이트 + 수치 대조 (+ 모델 근거 대조). */
async function inspect(brief, out, ctx, row) {
const { data, body } = assemble(brief, out, ctx.sources, out.pending ?? []);
const md = toMarkdown(data, body);
const g = gatePost(md, gateCtx);
const texts = [...out.summary.map((s) => ({ field: 'summary', text: s })), { field: 'body', text: out.body }, ...out.faq.flatMap((f) => [{ field: 'faq.q', text: f.q }, { field: 'faq.a', text: f.a }])];
const nums = mock ? [] : checkNumbersGrounded(texts, ctx.text);
let verify = null;
if (doVerify) {
const r = await callModel([{ role: 'system', content: VERIFY_SYSTEM }, { role: 'user', content: verifyPrompt({ context: ctx.text, post: out }) }], { schema: VERIFY_SCHEMA, useModel: verifyModel, temp: 0 });
addUsage(row, r.usage, r.model);
verify = r.out;
}
// 검증기가 "근거에 있다"고 적으면서 올린 항목은 오탐이다. supported=true 또는 그런 사유 문구는 버린다.
const SELF_CONTRADICT = /명시되어 있|근거에 있|일치합니다|일치함|확인됨|부합|있으므로 문제|근거에 명시/;
const real = (list) => (list ?? []).filter((u) => !u.supported && !SELF_CONTRADICT.test(u.reason ?? ''));
const verifyErrors = [
...real(verify?.unsupported).map((u) => ({ level: R.E, code: 'UNSUPPORTED_CLAIM', msg: `근거 없음: "${u.claim.slice(0, 80)}" (${u.reason.slice(0, 60)})` })),
...real(verify?.distorted).map((u) => ({ level: R.E, code: 'DISTORTED_CLAIM', msg: `근거와 다름: "${u.claim.slice(0, 80)}" ← 근거 "${u.evidence.slice(0, 50)}" (${u.reason.slice(0, 50)})` })),
];
if (verify) verify.dropped = (verify.unsupported?.length ?? 0) + (verify.distorted?.length ?? 0) - verifyErrors.length;
const errors = [...g.errors, ...nums.filter((x) => x.level === R.E), ...verifyErrors];
const warns = [...g.warns, ...nums.filter((x) => x.level === R.W)];
return { md, errors, warns, stats: g.stats, verify };
}
// ---------- 실행 ----------
mkdirSync(join(OUT, 'posts'), { recursive: true });
mkdirSync(join(OUT, 'prompts'), { recursive: true });
writeFileSync(join(OUT, 'briefs.json'), JSON.stringify(briefs, null, 1));
const gateCtx = { physicians: authors.physicians, supporters: authors.supporters, homeText: ev.homeText };
const report = { clinic, model: mock ? 'mock' : model, verifyModel: doVerify ? verifyModel : null, today, startedAt: new Date().toISOString(), priceIn, priceOut, warnThreshold, posts: [] };
let spent = 0;
for (const brief of briefs) {
const t0 = Date.now();
const row = { id: brief.id, title: brief.title, category: brief.category, calls: 0, usage: { prompt_tokens: 0, completion_tokens: 0 }, costUsd: 0 };
report.posts.push(row);
if (shouldStop({ spentUsd: spent })) { row.status = 'skipped_cost_cap'; console.log(` – ${brief.id}: 비용 상한 도달, 건너뜀`); continue; }
const ctx = buildContext(brief, { ev, fact, authors, videos, news, answers, today });
row.missingEvidence = ctx.missing;
row.contextChars = ctx.text.length;
const reviewerName = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate]?.name;
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt({ brief, context: ctx.text, fact, authorName: authors.supporters?.[authorId]?.name, reviewerName: reviewerName ? `${reviewerName} 원장` : null, today }) },
];
writeFileSync(join(OUT, 'prompts', `${brief.id}.txt`), messages.map((m) => `### ${m.role}\n${m.content}`).join('\n\n'));
if (dryRun) { row.status = 'dry_run'; console.log(` · ${brief.id}: 프롬프트 ${ctx.text.length}자 저장${ctx.missing.length ? ` · 근거 누락 ${ctx.missing.length}` : ''}`); continue; }
try {
let out, raw;
if (mock) { out = goldenOutput(brief); raw = JSON.stringify(out); }
else { const r = await callModel(messages); out = postProcess(r.out); raw = r.raw; addUsage(row, r.usage, r.model); }
let res = await inspect(brief, out, ctx, row);
row.firstPass = { errors: res.errors.map((e) => e.code), warns: res.warns.length, unsupported: res.verify?.unsupported?.length ?? 0, distorted: res.verify?.distorted?.length ?? 0, verifierDropped: res.verify?.dropped ?? 0 };
// 수정 최대 N회(--max-repairs, 기본 2): 오류가 있거나 경고가 임계 이상이면 오류·경고 목록을 되돌려 같은 스키마로 재출력
let convo = [...messages, { role: 'assistant', content: raw }];
for (let k = 0; k < maxRepairs && !mock && (res.errors.length || res.warns.length >= warnThreshold); k++) {
const fix = { role: 'user', content: repairPrompt(res.errors, res.warns) };
const r2 = await callModel([...convo, fix]);
addUsage(row, r2.usage, r2.model);
convo = [...convo, fix, { role: 'assistant', content: r2.raw }];
out = postProcess(r2.out);
res = await inspect(brief, out, ctx, row);
row.repaired = (row.repaired ?? 0) + 1;
}
writeFileSync(join(OUT, 'posts', `${brief.id}.md`), res.md);
const hard = res.errors.filter((e) => !['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
const soft = res.errors.filter((e) => ['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
row.status = hard.length ? 'gate_failed' : soft.length ? 'ok_needs_review' : 'ok';
row.errors = hard.map((e) => `[${e.code}] ${e.msg}`);
row.claimFlags = soft.map((e) => `[${e.code}] ${e.msg}`);
row.warnings = res.warns.map((w) => `[${w.code}] ${w.msg}`);
row.stats = res.stats;
row.pending = out.pending ?? [];
row.usedRefs = out.usedRefs ?? [];
row.seconds = Math.round((Date.now() - t0) / 100) / 10;
spent += row.costUsd;
const mark = hard.length ? '✗' : soft.length ? '△' : '✓';
console.log(` ${mark} ${brief.id}: 오류 ${hard.length} 근거플래그 ${soft.length} 경고 ${res.warns.length} · 본문 ${res.stats.hangul}자 표 ${res.stats.tables} FAQ ${res.stats.faq} · 확인 대기 ${row.pending.length} · 호출 ${row.calls}${row.repaired ? '(수정)' : ''} · $${row.costUsd.toFixed(3)} · ${row.seconds}s`);
for (const e of hard) console.log(` ✗ [${e.code}] ${e.msg}`);
for (const e of soft) console.log(` △ [${e.code}] ${e.msg}`);
} catch (e) {
row.status = 'error'; row.error = String(e.message ?? e);
console.error(` ✗ ${brief.id}: ${row.error.slice(0, 300)}`);
}
}
function addUsage(row, u, m) { row.calls++; row.usage.prompt_tokens += u.prompt_tokens ?? 0; row.usage.completion_tokens += u.completion_tokens ?? 0; row.costUsd += costOf(u, m); }
// ---------- 리포트 ----------
report.finishedAt = new Date().toISOString();
report.totalCostUsd = Math.round(spent * 1000) / 1000;
const st = (s) => report.posts.filter((p) => p.status === s).length;
report.summary = {
total: report.posts.length, ok: st('ok'), okNeedsReview: st('ok_needs_review'), gateFailed: st('gate_failed'), error: st('error'),
repaired: report.posts.filter((p) => p.repaired).length,
avgCostUsd: avg(report.posts.filter((p) => p.costUsd).map((p) => p.costUsd)),
avgSeconds: avg(report.posts.filter((p) => p.seconds).map((p) => p.seconds)),
firstPassErrorCodes: count(report.posts.flatMap((p) => p.firstPass?.errors ?? [])),
warningCodes: count(report.posts.flatMap((p) => (p.warnings ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
errorCodes: count(report.posts.flatMap((p) => (p.errors ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
claimFlags: report.posts.reduce((n, p) => n + (p.claimFlags?.length ?? 0), 0),
};
writeFileSync(join(OUT, 'report.json'), JSON.stringify(report, null, 1));
writeFileSync(join(OUT, 'report.md'), reportMd(report));
console.log(`\n통과 ${report.summary.ok} · 근거 플래그 남음 ${report.summary.okNeedsReview} · 게이트 실패 ${report.summary.gateFailed} · 오류 ${report.summary.error} / ${report.summary.total} · 수정 호출 ${report.summary.repaired} · 총 $${report.totalCostUsd} (편당 $${report.summary.avgCostUsd}) · ${OUT}`);
if (report.summary.gateFailed || report.summary.error) process.exitCode = 1;
function avg(a) { return a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : 0; }
function count(list) { return list.filter(Boolean).reduce((m, k) => ((m[k] = (m[k] ?? 0) + 1), m), {}); }
function reportMd(r) {
const rows = r.posts.map((p) => `| ${p.id} | ${p.category} | ${p.status} | ${p.errors?.length ?? ''} | ${p.claimFlags?.length ?? ''} | ${p.warnings?.length ?? ''} | ${p.stats?.hangul ?? ''} | ${p.stats?.tables ?? ''} | ${p.stats?.faq ?? ''} | ${p.pending?.length ?? ''} | ${p.calls}${p.repaired ? '+' : ''} | ${p.usage.prompt_tokens}/${p.usage.completion_tokens} | ${p.costUsd ? p.costUsd.toFixed(3) : ''} | ${p.seconds ?? ''} |`);
const flags = r.posts.filter((p) => p.claimFlags?.length).map((p) => `### ${p.id}\n${p.claimFlags.map((x) => `- ${x}`).join('\n')}`);
const pend = r.posts.filter((p) => p.pending?.length).map((p) => `### ${p.id}\n${p.pending.map((x) => `- ${x}`).join('\n')}`);
const fp = Object.entries(r.summary.firstPassErrorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음';
return `# 글 생성 리포트 · ${r.clinic} · ${r.model}${r.verifyModel ? ` (대조 ${r.verifyModel})` : ''} · ${r.today}
통과 ${r.summary.ok} · 근거 플래그 남음 ${r.summary.okNeedsReview} · 게이트 실패 ${r.summary.gateFailed} · 오류 ${r.summary.error} / ${r.summary.total} · 수정 호출 ${r.summary.repaired}편 · 총 $${r.totalCostUsd} · 편당 $${r.summary.avgCostUsd} · 편당 ${r.summary.avgSeconds}s
가격 기준 USD/1M tokens 입력 ${r.priceIn} · 출력 ${r.priceOut} (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): ${fp}
최종 경고 분포: ${Object.entries(r.summary.warningCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
최종 오류 분포: ${Object.entries(r.summary.errorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
${rows.join('\n')}
## 근거 대조 플래그 (사람이 확인할 것)
${flags.join('\n\n') || '(없음)'}
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
${pend.join('\n\n') || '(없음)'}
`;
}

View File

@ -0,0 +1,6 @@
---
// 글 첫 부분의 지원 관계 고지 (v2 §12-2, 추천·보증 심사지침: 제목 또는 첫 부분에 표시).
// site.sponsorNotice 가 비어 있으면 아무것도 렌더하지 않는다. 켜는 것은 haewon 승인 후.
import { SPONSOR_NOTICE } from '../lib';
---
{SPONSOR_NOTICE && <p class="disclosure disclosure-top">{SPONSOR_NOTICE}</p>}

View File

@ -15,6 +15,8 @@ const posts = defineCollection({
reviewer: z.string().optional(), // 감수자 id (physicians)
reviewStatus: z.enum(['reviewed', 'pending']).default('pending'),
reviewedAt: z.string().optional(), // 의학 검토 완료일. reviewed일 때 필수로 쓴다
approvedBy: z.string().optional(), // 게시 승인자(의료기관 개설자·직함). v2 §12-2. 병원 입력으로만 채운다
approvedAt: z.string().optional(), // 게시 승인일. 색인 허용 빌드(PUBLIC_INDEXABLE=true)는 이 값이 있는 글만 낸다
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(),

View File

@ -0,0 +1,4 @@
{
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
"items": []
}

View File

@ -20,5 +20,6 @@
"arrivalGuide": "",
"visitReads": [],
"newsNote": "",
"newsOutletsExample": ""
"newsOutletsExample": "",
"sponsorNotice": ""
}

View File

@ -15,6 +15,11 @@ export const CATEGORY_LABELS: Record<string, string> = {
export { fact, authors, site };
/** 지원 관계 고지(글 첫 부분). site.sponsorNotice 가 비어 있으면 상단 고지를 렌더하지 않는다(v2 §12-2, haewon 승인 후 켠다). */
export const SPONSOR_NOTICE: string = (site as AnyRec).sponsorNotice || '';
/** 색인 허용 빌드에서는 개설자 게시 승인(approvedAt)이 있는 글만 낸다. 미리보기(noindex)는 전부. */
export const isPublishable = (p: { data: { approvedAt?: string } }): boolean => !INDEXABLE || has(p.data.approvedAt);
/** 비어 있으면 PENDING. 화면 표시용. */
export const v = (x: unknown, fallback: string = PENDING): string => (x === null || x === undefined || x === '' ? fallback : String(x));
/** 비어 있지 않은 값만. 스키마·링크용. */

View File

@ -2,13 +2,13 @@
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
import { authors, personSchema } from '../../lib';
import { authors, personSchema, isPublishable } 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 posts = (await getCollection('posts')).filter(isPublishable).filter((p) => p.data.author === id);
const site = Astro.site!.toString().replace(/\/$/, '');
---
<Base title={`${a.name} · 작성 글`} description={a.bio} jsonLd={personSchema(id!, site)}>

View File

@ -5,8 +5,8 @@ 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));
import { SITE_NAME, fact, site as S, clinicSchema, surface, v, has, PENDING, isPublishable } from '../lib';
const posts = (await getCollection('posts')).filter(isPublishable).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, '병원');

View File

@ -11,10 +11,11 @@ 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';
import DisclosureTop from '../../components/DisclosureTop.astro';
import { CATEGORY_LABELS, SITE_NAME, clinicSchema, personSchema, physicianSchema, physician, supporter, fact, surface, isPublishable } from '../../lib';
export async function getStaticPaths() {
const posts = await getCollection('posts');
const posts = (await getCollection('posts')).filter(isPublishable);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
@ -60,10 +61,12 @@ if (d.faq.length) graph.push({ '@type': 'FAQPage', '@id': `${url}#faq`, mainEnti
<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>}
{d.approvedAt && <span>게시 승인 {d.approvedBy ?? ''} ({d.approvedAt})</span>}
<span>발행 {d.datePublished}</span>
<span>갱신 {d.dateModified}</span>
</div>
</header>
<DisclosureTop />
<Summary items={d.summary} />
{d.hero && (
<figure class="figure">

View File

@ -2,8 +2,8 @@
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));
import { CATEGORY_LABELS, SITE_NAME, fact, v, isPublishable } from '../../lib';
const posts = (await getCollection('posts')).filter(isPublishable).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 })) };

View File

@ -1,9 +1,10 @@
import type { APIRoute } from 'astro';
import { isPublishable } from '../lib';
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 posts = (await getCollection('posts')).filter(isPublishable);
const today = new Date().toISOString().slice(0, 10);
const urls = [
{ loc: `${base}/`, lastmod: today },

View File

@ -0,0 +1,179 @@
// 서포터즈 자동 빌드 워커 (v1 §3 파이프라인, v2 §7-6, §12-3). 병원 URL 하나로 근거 수집 → 데이터 → 글 생성 → 빌드·게이트 → (배포) 까지 순서대로 돈다.
// 실행 환경에 독립적이다. 로컬·GitHub Actions·Trigger.dev 태스크 어디서든 `node workers/supporters-build/run.mjs ...` 로 부른다.
//
// node workers/supporters-build/run.mjs --clinic <id> --url <https://...> [--name 상호] [--work ~/supporters-builds/<id>]
// [--inputs inputs.json | --from-supabase] [--brief briefs.json] [--max-posts 6] [--max-cost-usd 3]
// [--deploy] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate]
//
// 단계: evidence → data → inputs → briefs → generate → build → deploy → done
// 단계마다 <work>/status.json 을 쓰고, --build-id 와 SUPABASE_SERVICE_ROLE_KEY 가 있으면 supporter_builds 행을 갱신한다.
// 게이트(check.mjs)가 error 를 내면 status=gate_failed 로 멈춘다. 사람 게이트(검토·승인)는 여기서 자동화하지 않는다.
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url)));
const SUP = join(ROOT, 'supporters');
const TEMPLATE = join(ROOT, 'templates', 'supporters-astro');
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 url = opt('url');
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
const today = new Date().toISOString().slice(0, 10);
const PHASES = ['evidence', 'data', 'inputs', 'briefs', 'generate', 'build', 'deploy', '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, ''); } })();
// ---------- 상태 ----------
const statusPath = join(WORK, 'status.json');
const status = existsSync(statusPath) ? JSON.parse(readFileSync(statusPath, 'utf8')) : { clinic, url, status: 'queued', phases: [], posts: [], createdAt: new Date().toISOString() };
async function setStatus(patch) {
Object.assign(status, patch, { updatedAt: new Date().toISOString() });
mkdirSync(WORK, { recursive: true });
writeFileSync(statusPath, JSON.stringify(status, null, 1));
const id = opt('build-id'); const sb = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (id && sb && key) {
const body = { status: status.status, phase: status.phase, phases: status.phases, posts: status.posts, preview_url: status.previewUrl ?? null, report: status.report ?? null, error: status.error ?? null, updated_at: new Date().toISOString() };
const res = await fetch(`${sb}/rest/v1/supporter_builds?id=eq.${id}`, { method: 'PATCH', headers: { apikey: key, authorization: `Bearer ${key}`, 'content-type': 'application/json', prefer: 'return=minimal' }, body: JSON.stringify(body) }).catch((e) => ({ ok: false, text: async () => String(e) }));
if (!res.ok) console.warn(` (supporter_builds 갱신 실패: ${res.status ?? ''} ${(await res.text()).slice(0, 120)})`);
}
}
function run(cmd, argv, { cwd = SUP, env = {} } = {}) {
console.log(` $ ${cmd} ${argv.join(' ')}`);
const r = spawnSync(cmd, argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env }, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const out = (r.stdout ?? '') + (r.stderr ?? '');
return { ok: r.status === 0, code: r.status, out };
}
const tail = (s, n = 12) => s.trim().split('\n').slice(-n).join('\n');
async function phase(name, fn) {
if (PHASES.indexOf(name) < PHASES.indexOf(startFrom)) { console.log(`· ${name}: 건너뜀 (--from ${startFrom})`); return; }
const entry = { phase: name, status: 'running', startedAt: new Date().toISOString() };
status.phases = status.phases.filter((p) => p.phase !== name).concat(entry);
await setStatus({ status: 'running', phase: name });
console.log(`\n▶ ${name}`);
try {
const summary = await fn();
Object.assign(entry, { status: 'ok', finishedAt: new Date().toISOString(), summary: summary ?? '' });
await setStatus({});
console.log(` ✓ ${name}${summary ? ` · ${summary}` : ''}`);
} catch (e) {
Object.assign(entry, { status: 'failed', finishedAt: new Date().toISOString(), log: String(e.message ?? e).slice(0, 4000) });
await setStatus({ status: e.gate ? 'gate_failed' : 'failed', error: String(e.message ?? e).slice(0, 2000) });
console.error(` ✗ ${name}: ${String(e.message ?? e).slice(0, 1500)}`);
process.exit(1);
}
}
const EV_ROOT = join(WORK, 'evidence'); const EV = join(EV_ROOT, clinic);
const SITE = join(WORK, 'site'); const DATA = join(SITE, 'src/data'); const POSTS = join(SITE, 'src/content/posts');
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const writeJson = (p, j) => writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
// 1. 근거 수집
await phase('evidence', () => {
const r = run('node', ['scripts/collect_evidence.mjs', '--clinic', clinic, '--url', url, '--out', EV_ROOT]);
if (!r.ok || !existsSync(join(EV, 'index.json'))) throw new Error(`근거 수집 실패\n${tail(r.out)}`);
const idx = readJson(join(EV, 'index.json'));
return `${idx.pages.length}페이지 · 유형 ${Object.entries(idx.byType).map(([k, v]) => `${k} ${v}`).join(', ')}`;
});
// 2. 템플릿 복제 + 팩트 시트·의료진 초안
await phase('data', () => {
if (!existsSync(TEMPLATE)) throw new Error(`템플릿 없음: ${TEMPLATE} (supporters 에서 npm run export:template)`);
rmSync(SITE, { recursive: true, force: true });
cpSync(TEMPLATE, SITE, { recursive: true, filter: (s) => !/\/(node_modules|dist|\.astro)(\/|$)/.test(s) });
for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); }
cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt'));
// 팩트 시트: 후보 중 첫 값. 사람이 대조하기 전까지 "확인 대기"가 붙는 필드가 많다(v2 §4-5).
const fact = readJson(join(DATA, 'factSheet.json'));
const draft = existsSync(join(EV, 'facts.draft.json')) ? readJson(join(EV, 'facts.draft.json')).candidates ?? {} : {};
const first = (k) => Object.keys(draft[k] ?? {})[0] ?? '';
const name = opt('name') || (readJson(join(EV, 'index.json')).pages.find((p) => p.type === 'home')?.title ?? '').replace(/\s*[|·-].*$/, '').trim();
Object.assign(fact, { name: name || fact.name, shortName: name || fact.shortName, url: url.replace(/\/$/, ''), phone: first('phone').replace(/\./g, '-'), representative: /^[가-힣]{2,4}$/.test(first('representative')) ? first('representative') : '', businessNo: first('businessNo'), checkedAt: today, nextCheck: new Date(Date.now() + 30 * 864e5).toISOString().slice(0, 10) });
const addr = first('address'); if (addr) { fact.address.full = addr; fact.address.street = addr.replace(/^(서울(특별시|시)?|경기도?|부산(광역시)?|인천(광역시)?|대구(광역시)?|대전(광역시)?|광주(광역시)?)\s*[가-힣]+구?\s*/, ''); fact.address.source = Object.values(draft.address ?? {})[0]?.[0] ?? ''; }
writeJson(join(DATA, 'factSheet.json'), fact);
// 의료진: 이름·직함·URL 만. 약력이 이미지 글자면 빈 배열(사람 입력).
const authors = readJson(join(DATA, 'authors.json'));
const doctors = existsSync(join(EV, 'doctors.json')) ? readJson(join(EV, 'doctors.json')).doctors ?? [] : [];
authors.physicians = Object.fromEntries(doctors.filter((d) => d.name).map((d) => [d.id, { name: d.name, title: d.title || '원장', credentials: d.credentials ?? [], url: d.url, sameAs: [], image: null, specialty: '' }]));
authors.supporters = authors.supporters && Object.keys(authors.supporters).length ? authors.supporters : { editor: { name: '확인 대기', role: '편집 책임', bio: '', email: '', sameAs: [] } };
writeJson(join(DATA, 'authors.json'), authors);
const site = readJson(join(DATA, 'site.json'));
Object.assign(site, { siteName: site.siteName || `${fact.shortName || '병원'} 서포터즈`, editorId: site.editorId || Object.keys(authors.supporters)[0], clinicDoctors: Object.keys(authors.physicians).slice(0, 4) });
writeJson(join(DATA, 'site.json'), site);
status.report = { ...(status.report ?? {}), physicians: Object.entries(authors.physicians).map(([id, p]) => ({ id, name: p.name, title: p.title })) };
return `${fact.shortName || '(상호 확인 대기)'} · 전화 ${fact.phone || '확인 대기'} · 의료진 ${doctors.length}명`;
});
// 3. 병원 입력 반영 (있으면)
await phase('inputs', () => {
if (!opt('inputs') && !flag('from-supabase')) return '입력 없음 (확인 대기로 발행)';
const argv = ['scripts/apply_inputs.mjs', '--clinic', clinic, ...(opt('inputs') ? ['--inputs', resolve(opt('inputs'))] : ['--from-supabase'])];
// apply_inputs 는 자기 폴더 기준으로 데이터를 읽으므로 사이트 복제본의 scripts 로 실행한다
cpSync(join(SUP, 'scripts', 'apply_inputs.mjs'), join(SITE, 'scripts', 'apply_inputs.mjs'));
const r = run('node', argv, { cwd: SITE });
if (!r.ok) throw new Error(tail(r.out));
return tail(r.out, 3);
});
// 4. 기획 목록
await phase('briefs', () => {
if (opt('brief')) { cpSync(resolve(opt('brief')), join(WORK, 'briefs.json')); return `사람이 준 brief 사용: ${opt('brief')}`; }
const r = run('node', ['scripts/default_briefs.mjs', '--clinic', clinic, '--evidence', EV, '--site', SITE, '--out', join(WORK, 'briefs.json'), '--max', opt('max-posts', '6')]);
if (!r.ok) throw new Error(tail(r.out));
return tail(r.out, 1);
});
// 5. 글 생성
await phase('generate', () => {
if (flag('skip-generate')) return '건너뜀 (--skip-generate)';
const GEN = join(WORK, 'gen');
const argv = ['scripts/generate_posts.mjs', '--clinic', clinic, '--site', SITE, '--evidence', EV, '--brief', join(WORK, 'briefs.json'), '--out', GEN, '--today', today];
if (opt('max-cost-usd')) argv.push('--max-cost-usd', opt('max-cost-usd'));
const r = run('node', argv);
if (!existsSync(join(GEN, 'report.json'))) throw new Error(`생성기 실패\n${tail(r.out, 20)}`);
const report = readJson(join(GEN, 'report.json'));
mkdirSync(POSTS, { recursive: true });
const accepted = report.posts.filter((p) => ['ok', 'ok_needs_review'].includes(p.status));
for (const p of accepted) cpSync(join(GEN, 'posts', `${p.id}.md`), join(POSTS, `${p.id}.md`));
status.posts = accepted.map((p) => ({ id: p.id, title: p.title, category: p.category, status: p.status, reviewStatus: 'pending', pending: p.pending ?? [], claimFlags: p.claimFlags ?? [] }));
status.report = { ...(status.report ?? {}), generator: report.summary, totalCostUsd: report.totalCostUsd };
if (!accepted.length) throw new Error(`통과한 글이 없음\n${tail(r.out, 20)}`);
return `${accepted.length}/${report.posts.length}편 채택 · $${report.totalCostUsd} · 게이트 실패 ${report.summary.gateFailed}`;
});
// 6. 빌드 + 게이트
await phase('build', () => {
const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE });
if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`);
const b = run('npx', ['astro', 'build'], { cwd: SITE });
if (!b.ok) throw new Error(`astro build 실패\n${tail(b.out, 25)}`);
const g = run('node', ['scripts/check.mjs', '--today', today], { cwd: SITE });
status.report = { ...(status.report ?? {}), gate: tail(g.out, 1) };
if (!g.ok) { const e = new Error(`발행 게이트 실패\n${tail(g.out, 30)}`); e.gate = true; throw e; }
return tail(g.out, 1);
});
// 7. 배포 (선택) : Vercel CLI 로 병원당 프로젝트 supporters-<id>, noindex(vercel.json 의 X-Robots-Tag) 유지
await phase('deploy', () => {
if (!flag('deploy')) { status.previewUrl = null; return '건너뜀 (--deploy 없음). dist/ 는 ' + join(SITE, 'dist'); }
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 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;
});
await phase('done', async () => { await setStatus({ status: 'preview' }); return `status.json → ${statusPath}`; });
console.log(`\n완료: ${status.posts.length}편 · ${status.previewUrl ?? '미배포'} · ${WORK}`);