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:
parent
64a3bce9a3
commit
7e515b70ae
@ -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. 검토 승인의 법적 책임 주체 · 제안
|
||||
근거 조문을 원문으로 확인한 결과다(법률 자문이 아니며, 계약 전 변호사 확인을 권한다).
|
||||
|
||||
@ -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
|
||||
- 관리 프로그램의 비용 포함 여부
|
||||
- 부위별 무제한 관리의 실제 적용 범위와 횟수
|
||||
- 홈케어 제품 제공 조건 및 추가 비용 여부
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
205
src/components/discovery/ClinicInputsPanel.tsx
Normal file
205
src/components/discovery/ClinicInputsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
읽을 수는 있지만, 인용할 근거가 없습니다.
|
||||
|
||||
@ -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"
|
||||
|
||||
42
supabase/migrations/20260907_supporter_builds.sql
Normal file
42
supabase/migrations/20260907_supporter_builds.sql
Normal 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 가 데이터 파일에 반영.';
|
||||
10
supporters/briefs/viewclinic/inputs.sample.json
Normal file
10
supporters/briefs/viewclinic/inputs.sample.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
141
supporters/scripts/apply_inputs.mjs
Normal file
141
supporters/scripts/apply_inputs.mjs
Normal 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);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
78
supporters/scripts/default_briefs.mjs
Normal file
78
supporters/scripts/default_briefs.mjs
Normal 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(', ')})` : ''}`);
|
||||
12
supporters/scripts/gate/fixtures/approved-before-review.md
Normal file
12
supporters/scripts/gate/fixtures/approved-before-review.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [APPROVED_WITHOUT_BY, APPROVED_BEFORE_REVIEW]
|
||||
approvedAt: "2026-09-07"
|
||||
reviewStatus: pending
|
||||
---
|
||||
## 게시 승인 필드 검사
|
||||
|
||||
이 픽스처는 승인자 없이, 의학 검토 전에 게시 승인이 찍힌 경우입니다. 상담에서 확인하세요.
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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자 권장)` });
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
4
supporters/scripts/template/data/clinicAnswers.json
Normal file
4
supporters/scripts/template/data/clinicAnswers.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
|
||||
"items": []
|
||||
}
|
||||
@ -20,5 +20,6 @@
|
||||
"arrivalGuide": "",
|
||||
"visitReads": [],
|
||||
"newsNote": "",
|
||||
"newsOutletsExample": ""
|
||||
"newsOutletsExample": "",
|
||||
"sponsorNotice": ""
|
||||
}
|
||||
|
||||
6
supporters/src/components/DisclosureTop.astro
Normal file
6
supporters/src/components/DisclosureTop.astro
Normal 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>}
|
||||
@ -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(),
|
||||
|
||||
4
supporters/src/data/clinicAnswers.json
Normal file
4
supporters/src/data/clinicAnswers.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
|
||||
"items": []
|
||||
}
|
||||
@ -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));
|
||||
/** 비어 있지 않은 값만. 스키마·링크용. */
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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, '병원');
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 })) };
|
||||
|
||||
@ -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 },
|
||||
|
||||
3
templates/supporters-astro/.gitignore
vendored
3
templates/supporters-astro/.gitignore
vendored
@ -23,3 +23,6 @@ pnpm-debug.log*
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
.vercel
|
||||
|
||||
# 글 생성기 산출물 (리포트는 docs 로 옮겨 커밋)
|
||||
generated/
|
||||
|
||||
54
templates/supporters-astro/briefs/README.md
Normal file
54
templates/supporters-astro/briefs/README.md
Normal 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` 로 수정 대상이 된다.
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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" }
|
||||
]
|
||||
}
|
||||
54
templates/supporters-astro/package-lock.json
generated
54
templates/supporters-astro/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
141
templates/supporters-astro/scripts/apply_inputs.mjs
Normal file
141
templates/supporters-astro/scripts/apply_inputs.mjs
Normal 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);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
296
templates/supporters-astro/scripts/collect_evidence.mjs
Normal file
296
templates/supporters-astro/scripts/collect_evidence.mjs
Normal 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(', ')}`);
|
||||
78
templates/supporters-astro/scripts/default_briefs.mjs
Normal file
78
templates/supporters-astro/scripts/default_briefs.mjs
Normal 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(', ')})` : ''}`);
|
||||
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [APPROVED_WITHOUT_BY, APPROVED_BEFORE_REVIEW]
|
||||
approvedAt: "2026-09-07"
|
||||
reviewStatus: pending
|
||||
---
|
||||
## 게시 승인 필드 검사
|
||||
|
||||
이 픽스처는 승인자 없이, 의학 검토 전에 게시 승인이 찍힌 경우입니다. 상담에서 확인하세요.
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
@ -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>
|
||||
@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
149
templates/supporters-astro/scripts/gen/evidence.mjs
Normal file
149
templates/supporters-astro/scripts/gen/evidence.mjs
Normal 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 ?? '?'); }
|
||||
151
templates/supporters-astro/scripts/gen/prompt.mjs
Normal file
151
templates/supporters-astro/scripts/gen/prompt.mjs
Normal 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'],
|
||||
},
|
||||
};
|
||||
64
templates/supporters-astro/scripts/gen/serialize.mjs
Normal file
64
templates/supporters-astro/scripts/gen/serialize.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
359
templates/supporters-astro/scripts/generate_posts.mjs
Normal file
359
templates/supporters-astro/scripts/generate_posts.mjs
Normal 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') || '(없음)'}
|
||||
`;
|
||||
}
|
||||
@ -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>}
|
||||
@ -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(),
|
||||
|
||||
4
templates/supporters-astro/src/data/clinicAnswers.json
Normal file
4
templates/supporters-astro/src/data/clinicAnswers.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"_comment": "병원이 서면으로 확인해 준 답변(v2 §6 4~7항). apply_inputs.mjs 가 채우고 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.",
|
||||
"items": []
|
||||
}
|
||||
@ -20,5 +20,6 @@
|
||||
"arrivalGuide": "",
|
||||
"visitReads": [],
|
||||
"newsNote": "",
|
||||
"newsOutletsExample": ""
|
||||
"newsOutletsExample": "",
|
||||
"sponsorNotice": ""
|
||||
}
|
||||
|
||||
@ -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));
|
||||
/** 비어 있지 않은 값만. 스키마·링크용. */
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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, '병원');
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 })) };
|
||||
|
||||
@ -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 },
|
||||
|
||||
179
workers/supporters-build/run.mjs
Normal file
179
workers/supporters-build/run.mjs
Normal 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}`);
|
||||
Loading…
Reference in New Issue
Block a user