From ee8c06f53b7d6562247efea6bcfba3e95671b69b Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Mon, 7 Sep 2026 18:54:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(supporters):=20haewon=20=EA=B2=B0=EC=A0=95?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=A0=84=EC=B2=B4=20=ED=83=AD=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EB=8B=A8=EA=B0=80=20=EC=83=81=ED=95=9C=20$10=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8,=20=ED=81=90=20=ED=8F=B4=EB=9F=AC(poll.mjs)+?= =?UTF-8?q?launchd,=20/supporters/:id=20=ED=8E=98=EC=9D=B4=EC=A7=80,=20URL?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=E2=86=92=EB=B9=8C=EB=93=9C=20=ED=81=90,?= =?UTF-8?q?=20=EC=88=98=EC=A7=91=EA=B8=B0=20UA=C2=B7=EC=8A=AC=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95,=20=EA=B8=B0=ED=9A=8D=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=9D=BC=EB=B0=98=ED=99=94(=EC=9B=90?= =?UTF-8?q?=EC=A7=84=206=ED=8E=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP: 원진성형외과 회귀는 글 생성 단계에서 네트워크 끊김(fetch failed)으로 중단. 다음 세션에 --from generate 로 재실행. Co-Authored-By: Claude Fable 5.1 --- .../discovery/DiscoveryLeadModal.tsx | 8 + src/main.tsx | 2 + src/pages/SupportersBuildPage.tsx | 32 ++++ .../20260907_supporter_builds_queue.sql | 4 + supporters/scripts/check.mjs | 3 +- supporters/scripts/collect_evidence.mjs | 13 +- supporters/scripts/default_briefs.mjs | 138 +++++++++++------- supporters/scripts/generate_posts.mjs | 3 +- supporters/src/pages/videos.astro | 6 +- templates/supporters-astro/scripts/check.mjs | 3 +- .../scripts/collect_evidence.mjs | 13 +- .../scripts/default_briefs.mjs | 138 +++++++++++------- .../scripts/generate_posts.mjs | 3 +- .../supporters-astro/src/pages/videos.astro | 6 +- workers/supporters-build/README.md | 40 +++++ .../com.infinith.supporters-poll.plist | 29 ++++ workers/supporters-build/poll.mjs | 62 ++++++++ workers/supporters-build/run.mjs | 2 +- 18 files changed, 380 insertions(+), 125 deletions(-) create mode 100644 src/pages/SupportersBuildPage.tsx create mode 100644 supabase/migrations/20260907_supporter_builds_queue.sql create mode 100644 workers/supporters-build/README.md create mode 100644 workers/supporters-build/launchd/com.infinith.supporters-poll.plist create mode 100644 workers/supporters-build/poll.mjs diff --git a/src/components/discovery/DiscoveryLeadModal.tsx b/src/components/discovery/DiscoveryLeadModal.tsx index 15d6a3e..c0ded2a 100644 --- a/src/components/discovery/DiscoveryLeadModal.tsx +++ b/src/components/discovery/DiscoveryLeadModal.tsx @@ -62,12 +62,20 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal } setFieldError(null); setState('submitting'); + // discovery_leads 는 anon 조회 정책이 없어 insert 반환값을 읽지 않는다. const { error } = await supabase.from('discovery_leads').insert({ url: normalized, clinic_name: clinicName.trim() || null, contact: contact.trim(), source: 'discovery_landing', }); + // 서포터즈 자동 빌드 큐(supporter_builds, status=queued). 워커(workers/supporters-build/poll.mjs)가 집어 간다. + // clinic_id 는 홈페이지 호스트에서 만든다 (www.k-wonjin.co.kr → k-wonjin). 큐 삽입이 실패해도 신청 자체는 접수된 것으로 본다. + if (!error) { + const host = (() => { try { return new URL(normalized).host; } catch { return normalized; } })(); + const clinicId = host.replace(/^www\./, '').split('.')[0].toLowerCase().replace(/[^a-z0-9-]/g, '') || 'clinic'; + await supabase.from('supporter_builds').insert({ clinic_id: clinicId, url: normalized, status: 'queued', clinic_name: clinicName.trim() || null }); + } setState(error ? 'error' : 'done'); }; diff --git a/src/main.tsx b/src/main.tsx index ba528d5..761c1e9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,7 @@ import ApiDashboardPage from './pages/ApiDashboardPage.tsx'; import LoginPage from './pages/LoginPage.tsx'; import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx'; import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx'; +import SupportersBuildPage from './pages/SupportersBuildPage.tsx'; import './index.css'; createRoot(document.getElementById('root')!).render( @@ -40,6 +41,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> diff --git a/src/pages/SupportersBuildPage.tsx b/src/pages/SupportersBuildPage.tsx new file mode 100644 index 0000000..d28c843 --- /dev/null +++ b/src/pages/SupportersBuildPage.tsx @@ -0,0 +1,32 @@ +/** + * /supporters/:id — 서포터즈 자동 빌드 상태 + 병원 확인 항목 (진단 리포트가 아직 없는 병원용) + * /discovery/:id 리포트가 있는 병원은 리포트 안의 같은 패널을 쓴다. 이 페이지는 URL 신청만 있고 리포트가 없는 병원에게 보내는 링크다. + */ +import { useParams, Link } from 'react-router'; +import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel'; + +export default function SupportersBuildPage() { + const { id } = useParams<{ id: string }>(); + if (!id) return null; + return ( +
+
+
+
+
+ INFINITH AI Discovery · Supporters Build +
+

+ Supporters Preview +

+

병원 ID {id}

+

+ 홈페이지 공개 자료만으로 만든 서포터즈 사이트 초안입니다. 아래에서 미리보기를 열고, 병원만 답할 수 있는 항목을 입력해 주세요. + {' '}AI Discovery 소개 +

+
+
+ +
+ ); +} diff --git a/supabase/migrations/20260907_supporter_builds_queue.sql b/supabase/migrations/20260907_supporter_builds_queue.sql new file mode 100644 index 0000000..39ad8f3 --- /dev/null +++ b/supabase/migrations/20260907_supporter_builds_queue.sql @@ -0,0 +1,4 @@ +-- 랜딩 URL 신청이 곧바로 빌드 큐가 되게 한다 (v2 §7-6). anon 은 status='queued' 행만 넣을 수 있고, 갱신은 워커(service_role) 전용. +alter table public.supporter_builds add column if not exists clinic_name text; +create policy "anon can queue builds" on public.supporter_builds + for insert to anon with check (status = 'queued'); diff --git a/supporters/scripts/check.mjs b/supporters/scripts/check.mjs index 6c919b1..0a38029 100644 --- a/supporters/scripts/check.mjs +++ b/supporters/scripts/check.mjs @@ -99,8 +99,7 @@ for (const f of posts) { add('data/factSheet.json', R.checkNextCheck(fact, today)); add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' })); add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' })); -// "채널 인기 전체" 탭은 제거 여부가 haewon 결정 대기(v2 §9-1). 결정 전까지 경고만. -add('data/videos.json', R.checkVideoTitles(videos.top, { level: R.W, label: '채널 인기 전체 탭(결정 대기)' })); +// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다. // ---------- 출력 ---------- const errors = findings.filter((x) => x.level === R.E); diff --git a/supporters/scripts/collect_evidence.mjs b/supporters/scripts/collect_evidence.mjs index 9a15e63..8160037 100644 --- a/supporters/scripts/collect_evidence.mjs +++ b/supporters/scripts/collect_evidence.mjs @@ -21,7 +21,7 @@ const CLINIC = arg('--clinic'); const START = arg('--url'); if (!CLINIC || !START) { console.error('사용법: --clinic --url '); 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 UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)'; // 일부 병원(원진)은 비브라우저 UA 를 403 으로 막는다. 브라우저 UA + 식별 문자열 const today = new Date().toISOString().slice(0, 10); // ---------- 페이지 유형 ---------- @@ -82,8 +82,10 @@ function normalize(href, base) { const u = new URL(href, base); if (!/^https?:$/.test(u.protocol)) return null; u.hash = ''; u.search = ''; + // 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다. + // 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다. let s = u.toString(); - if (!/\.[a-z0-9]{2,5}$/i.test(u.pathname) && !s.endsWith('/')) s += '/'; + if (u.pathname !== '/' && s.endsWith('/')) s = s.slice(0, -1); return s; } catch { return null; } } @@ -218,7 +220,12 @@ console.log(` 유형 확정 ${typedRest.length} · 의료진 상세 ${doctorDet 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); + let r = await fetchText(t.url); + if (r.status === 404) { // 슬래시 유무 반대 형태 재시도 + const alt = t.url.endsWith('/') ? t.url.slice(0, -1) : t.url + '/'; + const r2 = await fetchText(alt); + if (r2.status === 200) r = r2; + } 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; diff --git a/supporters/scripts/default_briefs.mjs b/supporters/scripts/default_briefs.mjs index 0bced5f..ab0a823 100644 --- a/supporters/scripts/default_briefs.mjs +++ b/supporters/scripts/default_briefs.mjs @@ -1,5 +1,8 @@ // 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11). // 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다. +// 병원마다 페이지 구성이 다르므로(뷰: 주의사항·애프터케어·안전 페이지 있음 / 원진: 시술 페이지·소개·예약만 텍스트) 두 층으로 고른다. +// 1층: 전용 페이지(precautions·aftercare·safety·direction)가 있을 때의 문항 +// 2층: 어느 병원에나 있는 근거(시술 상세·병원 소개·의료진·예약)로 만드는 문항 // 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다. // // node scripts/default_briefs.mjs --clinic --evidence > --site [--out briefs.json] [--max 6] @@ -16,63 +19,92 @@ 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 hasBatchim = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 !== 0 : false; }; +const eun = (w) => (hasBatchim(w) ? '은' : '는'); + +// 글 근거로 쓰지 않는 페이지: 약관·개인정보·이벤트·자가진단·학술·채용·게시판 목록 +const NOISE = /약관|개인정보|policy|private|terms|event|이벤트|자가진단|trtest|학술|academic|채용|recruit|board|notice|news|blog|login|join|cart|sitemap/i; +const pages = index.pages.filter((p) => p.status === 200 && p.chars >= 200 && !NOISE.test(p.url + ' ' + (p.title ?? ''))); +const uniq = (list) => { const seen = new Set(); return list.filter((p) => { const k = p.url.replace(/\/$/, '').toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); }; +const byType = (t) => uniq(pages.filter((p) => p.type === t)).sort((a, b) => b.chars - a.chars); +const urls = (...types) => [...new Set(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 anesth = physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep; -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, - }, -]; +// 시술 페이지를 영역(URL 의 시술 세그먼트)별로 묶는다. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast +const PROC_WORD = /(성형|수술|술|리프팅|보톡스|필러|이식|교정|축소|확대|재배치|절개|매몰|거상)/; +const procPages = byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(decodeURIComponent(p.url))); +const areaOf = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); return (i >= 0 ? segs[i + 1] : segs[0]) ?? 'etc'; }; +const areas = new Map(); +for (const p of procPages) { const a = areaOf(p.url); if (!areas.has(a)) areas.set(a, []); areas.get(a).push(p); } +const topAreas = [...areas.entries()].map(([area, list]) => ({ area, list: list.sort((a, b) => b.chars - a.chars), chars: list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, 3); +const areaLabel = (a) => (a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area; -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 AVOID = ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담']; +const candidates = []; +const add = (c) => candidates.push(c); -const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', posts }; +// ---------- 1층: 전용 페이지가 있을 때 ---------- +if (byType('precautions').length) add({ + id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'], + title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?', + intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.', + pages: urls('precautions', 'aftercare'), reviewer: rep, +}); +if (byType('safety').length) add({ + id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'], + title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`, + intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.', + pages: urls('safety', 'checkup'), reviewer: anesth, +}); +if (byType('aftercare').length) add({ + id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'], + title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?', + intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".', + pages: urls('aftercare', 'checkup'), reviewer: rep, +}); +if (byType('safety').length || byType('aftercare').length) add({ + id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'], + title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?', + intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.', + pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep, +}); + +// ---------- 2층: 어느 병원에나 있는 근거 ---------- +if (byType('about').length || byType('doctor').length) add({ + id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'], + title: `${name}${eun(name)} 어떤 병원인가요? 진료 분야·의료진 구성·위치`, + intent: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.', + pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep, +}); +if (byType('direction').length || byType('reservation').length) add({ + id: 'visit-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-01', 'B-03', 'B-05'], + title: `${name} 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?`, + intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길·예약 페이지만 근거로. 확인되지 않은 시간·주차는 "전화 확인".', + pages: urls('direction', 'reservation', 'about'), reviewer: null, +}); +for (const a of topAreas) add({ + id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'], + title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`, + intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`, + pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, +}); +if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({ + id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'], + title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?', + intent: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 질문마다 왜 묻는지 한 줄. 답을 적는 칸.', + pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep, +}); + +const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, ...c }) => ({ + ...c, avoid: 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).', evidenceTypes: index.byType, 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(', ')})` : ''}`); +console.log(`brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`); diff --git a/supporters/scripts/generate_posts.mjs b/supporters/scripts/generate_posts.mjs index 33aa433..3b114bc 100644 --- a/supporters/scripts/generate_posts.mjs +++ b/supporters/scripts/generate_posts.mjs @@ -35,7 +35,8 @@ 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; +// 단가 상한 (v2 §9-5): haewon 결정 2026-09-07, 한 번 실행에 $10. --max-cost-usd 0 이면 상한 없음. +const maxCostUsd = opt('max-cost-usd') === '0' ? Infinity : Number(opt('max-cost-usd', '10')); const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).replace('T', '-'); const OUT = resolve(opt('out', join(SUP, 'generated', clinic, stamp))); diff --git a/supporters/src/pages/videos.astro b/supporters/src/pages/videos.astro index fa5abc6..6c05ded 100644 --- a/supporters/src/pages/videos.astro +++ b/supporters/src/pages/videos.astro @@ -8,7 +8,7 @@ const gu = surface('gangnamunni'); type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number }; const site = Astro.site!.toString().replace(/\/$/, ''); const ch = (data.channel ?? {}) as Record; -const topLong = (data.topLong ?? []) as V[]; const topAll = (data.top ?? []) as V[]; +const topLong = (data.topLong ?? []) as V[]; const fetchedAt = data.fetchedAt ?? PENDING; const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString(); const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; @@ -16,11 +16,11 @@ const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[ const tabs: { key: string; label: string; desc: string; list: V[] }[] = [ { key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong }, { key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts }, - { key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: topAll }, + // "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거. 전후·후기 제목 영상이 섞여 사이트 원칙(§3-8)과 맞지 않는다. 집계 데이터(videos.top)는 참고용으로 남긴다. ]; const ld = [ { '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) }, - { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topAll.length, itemListElement: topAll.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, + { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topLong.length, itemListElement: topLong.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, ]; --- diff --git a/templates/supporters-astro/scripts/check.mjs b/templates/supporters-astro/scripts/check.mjs index 6c919b1..0a38029 100644 --- a/templates/supporters-astro/scripts/check.mjs +++ b/templates/supporters-astro/scripts/check.mjs @@ -99,8 +99,7 @@ for (const f of posts) { add('data/factSheet.json', R.checkNextCheck(fact, today)); add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' })); add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' })); -// "채널 인기 전체" 탭은 제거 여부가 haewon 결정 대기(v2 §9-1). 결정 전까지 경고만. -add('data/videos.json', R.checkVideoTitles(videos.top, { level: R.W, label: '채널 인기 전체 탭(결정 대기)' })); +// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다. // ---------- 출력 ---------- const errors = findings.filter((x) => x.level === R.E); diff --git a/templates/supporters-astro/scripts/collect_evidence.mjs b/templates/supporters-astro/scripts/collect_evidence.mjs index 9a15e63..8160037 100644 --- a/templates/supporters-astro/scripts/collect_evidence.mjs +++ b/templates/supporters-astro/scripts/collect_evidence.mjs @@ -21,7 +21,7 @@ const CLINIC = arg('--clinic'); const START = arg('--url'); if (!CLINIC || !START) { console.error('사용법: --clinic --url '); 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 UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)'; // 일부 병원(원진)은 비브라우저 UA 를 403 으로 막는다. 브라우저 UA + 식별 문자열 const today = new Date().toISOString().slice(0, 10); // ---------- 페이지 유형 ---------- @@ -82,8 +82,10 @@ function normalize(href, base) { const u = new URL(href, base); if (!/^https?:$/.test(u.protocol)) return null; u.hash = ''; u.search = ''; + // 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다. + // 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다. let s = u.toString(); - if (!/\.[a-z0-9]{2,5}$/i.test(u.pathname) && !s.endsWith('/')) s += '/'; + if (u.pathname !== '/' && s.endsWith('/')) s = s.slice(0, -1); return s; } catch { return null; } } @@ -218,7 +220,12 @@ console.log(` 유형 확정 ${typedRest.length} · 의료진 상세 ${doctorDet 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); + let r = await fetchText(t.url); + if (r.status === 404) { // 슬래시 유무 반대 형태 재시도 + const alt = t.url.endsWith('/') ? t.url.slice(0, -1) : t.url + '/'; + const r2 = await fetchText(alt); + if (r2.status === 200) r = r2; + } 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; diff --git a/templates/supporters-astro/scripts/default_briefs.mjs b/templates/supporters-astro/scripts/default_briefs.mjs index 0bced5f..ab0a823 100644 --- a/templates/supporters-astro/scripts/default_briefs.mjs +++ b/templates/supporters-astro/scripts/default_briefs.mjs @@ -1,5 +1,8 @@ // 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11). // 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다. +// 병원마다 페이지 구성이 다르므로(뷰: 주의사항·애프터케어·안전 페이지 있음 / 원진: 시술 페이지·소개·예약만 텍스트) 두 층으로 고른다. +// 1층: 전용 페이지(precautions·aftercare·safety·direction)가 있을 때의 문항 +// 2층: 어느 병원에나 있는 근거(시술 상세·병원 소개·의료진·예약)로 만드는 문항 // 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다. // // node scripts/default_briefs.mjs --clinic --evidence > --site [--out briefs.json] [--max 6] @@ -16,63 +19,92 @@ 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 hasBatchim = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 !== 0 : false; }; +const eun = (w) => (hasBatchim(w) ? '은' : '는'); + +// 글 근거로 쓰지 않는 페이지: 약관·개인정보·이벤트·자가진단·학술·채용·게시판 목록 +const NOISE = /약관|개인정보|policy|private|terms|event|이벤트|자가진단|trtest|학술|academic|채용|recruit|board|notice|news|blog|login|join|cart|sitemap/i; +const pages = index.pages.filter((p) => p.status === 200 && p.chars >= 200 && !NOISE.test(p.url + ' ' + (p.title ?? ''))); +const uniq = (list) => { const seen = new Set(); return list.filter((p) => { const k = p.url.replace(/\/$/, '').toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); }; +const byType = (t) => uniq(pages.filter((p) => p.type === t)).sort((a, b) => b.chars - a.chars); +const urls = (...types) => [...new Set(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 anesth = physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep; -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, - }, -]; +// 시술 페이지를 영역(URL 의 시술 세그먼트)별로 묶는다. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast +const PROC_WORD = /(성형|수술|술|리프팅|보톡스|필러|이식|교정|축소|확대|재배치|절개|매몰|거상)/; +const procPages = byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(decodeURIComponent(p.url))); +const areaOf = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); return (i >= 0 ? segs[i + 1] : segs[0]) ?? 'etc'; }; +const areas = new Map(); +for (const p of procPages) { const a = areaOf(p.url); if (!areas.has(a)) areas.set(a, []); areas.get(a).push(p); } +const topAreas = [...areas.entries()].map(([area, list]) => ({ area, list: list.sort((a, b) => b.chars - a.chars), chars: list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, 3); +const areaLabel = (a) => (a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area; -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 AVOID = ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담']; +const candidates = []; +const add = (c) => candidates.push(c); -const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', posts }; +// ---------- 1층: 전용 페이지가 있을 때 ---------- +if (byType('precautions').length) add({ + id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'], + title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?', + intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.', + pages: urls('precautions', 'aftercare'), reviewer: rep, +}); +if (byType('safety').length) add({ + id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'], + title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`, + intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.', + pages: urls('safety', 'checkup'), reviewer: anesth, +}); +if (byType('aftercare').length) add({ + id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'], + title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?', + intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".', + pages: urls('aftercare', 'checkup'), reviewer: rep, +}); +if (byType('safety').length || byType('aftercare').length) add({ + id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'], + title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?', + intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.', + pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep, +}); + +// ---------- 2층: 어느 병원에나 있는 근거 ---------- +if (byType('about').length || byType('doctor').length) add({ + id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'], + title: `${name}${eun(name)} 어떤 병원인가요? 진료 분야·의료진 구성·위치`, + intent: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.', + pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep, +}); +if (byType('direction').length || byType('reservation').length) add({ + id: 'visit-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-01', 'B-03', 'B-05'], + title: `${name} 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?`, + intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길·예약 페이지만 근거로. 확인되지 않은 시간·주차는 "전화 확인".', + pages: urls('direction', 'reservation', 'about'), reviewer: null, +}); +for (const a of topAreas) add({ + id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'], + title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`, + intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`, + pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, +}); +if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({ + id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'], + title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?', + intent: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 질문마다 왜 묻는지 한 줄. 답을 적는 칸.', + pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep, +}); + +const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, ...c }) => ({ + ...c, avoid: 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).', evidenceTypes: index.byType, 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(', ')})` : ''}`); +console.log(`brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`); diff --git a/templates/supporters-astro/scripts/generate_posts.mjs b/templates/supporters-astro/scripts/generate_posts.mjs index 33aa433..3b114bc 100644 --- a/templates/supporters-astro/scripts/generate_posts.mjs +++ b/templates/supporters-astro/scripts/generate_posts.mjs @@ -35,7 +35,8 @@ 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; +// 단가 상한 (v2 §9-5): haewon 결정 2026-09-07, 한 번 실행에 $10. --max-cost-usd 0 이면 상한 없음. +const maxCostUsd = opt('max-cost-usd') === '0' ? Infinity : Number(opt('max-cost-usd', '10')); const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).replace('T', '-'); const OUT = resolve(opt('out', join(SUP, 'generated', clinic, stamp))); diff --git a/templates/supporters-astro/src/pages/videos.astro b/templates/supporters-astro/src/pages/videos.astro index fa5abc6..6c05ded 100644 --- a/templates/supporters-astro/src/pages/videos.astro +++ b/templates/supporters-astro/src/pages/videos.astro @@ -8,7 +8,7 @@ const gu = surface('gangnamunni'); type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number }; const site = Astro.site!.toString().replace(/\/$/, ''); const ch = (data.channel ?? {}) as Record; -const topLong = (data.topLong ?? []) as V[]; const topAll = (data.top ?? []) as V[]; +const topLong = (data.topLong ?? []) as V[]; const fetchedAt = data.fetchedAt ?? PENDING; const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString(); const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; @@ -16,11 +16,11 @@ const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[ const tabs: { key: string; label: string; desc: string; list: V[] }[] = [ { key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong }, { key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts }, - { key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: topAll }, + // "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거. 전후·후기 제목 영상이 섞여 사이트 원칙(§3-8)과 맞지 않는다. 집계 데이터(videos.top)는 참고용으로 남긴다. ]; const ld = [ { '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) }, - { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topAll.length, itemListElement: topAll.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, + { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topLong.length, itemListElement: topLong.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, ]; --- diff --git a/workers/supporters-build/README.md b/workers/supporters-build/README.md new file mode 100644 index 0000000..d8d3083 --- /dev/null +++ b/workers/supporters-build/README.md @@ -0,0 +1,40 @@ +# 서포터즈 자동 빌드 워커 + +병원 홈페이지 URL 하나로 근거 수집 → 팩트 시트·의료진 초안 → 첫 배치 기획 → 글 생성(GPT-4.1, 게이트·근거 대조) → Astro 빌드·발행 게이트 → Vercel 미리보기(noindex) 까지 돈다. 사람 게이트(의학 검토·게시 승인)는 자동화하지 않는다. + +``` +node workers/supporters-build/run.mjs --clinic --url [--name 상호] [--deploy] [--build-id ] [--max-cost-usd 10] +node workers/supporters-build/poll.mjs [--loop 300] [--no-deploy] # supporter_builds 큐(status=queued)를 집어 run.mjs 실행 +``` + +## 흐름 + +1. 랜딩 `/discovery` 의 URL 신청(DiscoveryLeadModal) → `discovery_leads` + `supporter_builds`(queued) 행. +2. `poll.mjs` 가 queued 행을 집어(running 으로 조건부 갱신) `run.mjs` 를 돌린다. 단계마다 `supporter_builds` 갱신. +3. 완료되면 `preview_url` 이 기록되고, `/discovery/:id` 또는 `/supporters/:id` 의 "병원 확인 항목" 패널에 미리보기 링크와 입력 폼이 뜬다. +4. 병원이 입력하면 `supporter_inputs` 에 쌓인다. 재빌드: `run.mjs --from data --from-supabase --deploy --build-id ` (apply_inputs 가 데이터·frontmatter 에 반영). + +## 실행 위치 (권장) + +| 단계 | 어디서 | haewon 이 할 일 | +|---|---|---| +| 지금 (파일럿, 월 수 건) | haewon 의 Mac 에서 `poll.mjs` 를 launchd 로 5분마다. `launchd/` 폴더의 plist 를 설치하면 된다 | 설치 명령 한 번. Mac 이 켜져 있어야 돈다 | +| 정착 (월 수십 건, 무인) | **Trigger.dev 클라우드**. `poll.mjs` 와 같은 코드를 태스크로 올린다. 타임아웃 없음, 재시도·로그 내장, 무료 구간(월 1,500 run)이면 충분 | 가입(GitHub 계정), 프로젝트 만들고 API 키 1개 전달. 코드·배포는 Claude 가 한다 | + +VPS(서버 직접 운영)와 Vercel Functions(최대 800초)는 권하지 않는다. 전자는 관리 부담, 후자는 빌드가 시간 안에 끝나지 않는다. + +## 필요한 것 + +- `.env`: `VITE_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY` +- `vercel` CLI 로그인 (배포 시). 병원당 프로젝트 `supporters-` 를 만든다 +- `templates/supporters-astro/` (supporters 에서 `npm run export:template`) + +## 단가 + +글 생성 상한은 실행당 $10 (`--max-cost-usd`, haewon 결정 2026-09-07). 실측: 편당 $0.05~0.19, 첫 배치 5~6편이면 $0.3~1. + +## 두 번째 병원에서 배운 것 (원진성형외과, 2026-09-07) + +- 비브라우저 User-Agent 를 403 으로 막는다 → 브라우저 UA + 식별 문자열. +- 경로 끝 슬래시를 붙이면 404 (뷰성형외과는 반대로 슬래시로 301) → 슬래시를 강제하지 않고 404 면 반대 형태 재시도. +- 페이지 유형 분류가 URL 경로 어휘(system/precautions 등)에 기대면 다른 병원에서 못 잡는다 → 제목·본문 키워드 분류를 보강할 것. diff --git a/workers/supporters-build/launchd/com.infinith.supporters-poll.plist b/workers/supporters-build/launchd/com.infinith.supporters-poll.plist new file mode 100644 index 0000000..ff72a1e --- /dev/null +++ b/workers/supporters-build/launchd/com.infinith.supporters-poll.plist @@ -0,0 +1,29 @@ + + + + + Labelcom.infinith.supporters-poll + ProgramArguments + + /usr/local/bin/node + workers/supporters-build/poll.mjs + + WorkingDirectory/Users/haewonkam/orca/workspaces/INFINITH/Productize + EnvironmentVariables + + PATH/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + + StartInterval300 + RunAtLoad + StandardOutPath/Users/haewonkam/supporters-builds/poll.log + StandardErrorPath/Users/haewonkam/supporters-builds/poll.log + + diff --git a/workers/supporters-build/poll.mjs b/workers/supporters-build/poll.mjs new file mode 100644 index 0000000..5e4b4aa --- /dev/null +++ b/workers/supporters-build/poll.mjs @@ -0,0 +1,62 @@ +// 빌드 큐 폴러. supporter_builds 에서 status=queued 행을 하나 집어 run.mjs 를 돌린다 (v2 §12-3 워커 위치와 무관하게 동작). +// 어디서든 돈다: 지금은 haewon 의 Mac(launchd) 또는 Claude 세션에서 수동, 나중에 Trigger.dev 태스크·VPS cron 에 같은 파일을 올린다. +// +// node workers/supporters-build/poll.mjs 큐를 한 번 비우고 종료 (cron/launchd 용) +// node workers/supporters-build/poll.mjs --loop 60 60초마다 반복 (상주 프로세스) +// 옵션: --no-deploy --max-cost-usd 10 --work-root ~/supporters-builds +// +// 필요한 환경: .env 의 VITE_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, (배포 시) vercel CLI 로그인. +// 같은 행을 두 폴러가 집지 않도록 queued → running 갱신을 조건부 PATCH(status=eq.queued)로 하고, 갱신된 행이 없으면 건너뛴다. +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; + +const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))); +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}`); +(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 SB = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!SB || !KEY) { console.error('VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음'); process.exit(2); } +const H = { apikey: KEY, authorization: `Bearer ${KEY}`, 'content-type': 'application/json' }; +const WORK_ROOT = resolve(opt('work-root', join(homedir(), 'supporters-builds'))); +const loopSec = Number(opt('loop', '0')); + +async function nextQueued() { + const r = await fetch(`${SB}/rest/v1/supporter_builds?status=eq.queued&order=created_at.asc&limit=1&select=id,clinic_id,url,clinic_name`, { headers: H }); + const rows = await r.json(); + return rows[0] ?? null; +} +async function claim(id) { + const r = await fetch(`${SB}/rest/v1/supporter_builds?id=eq.${id}&status=eq.queued`, { method: 'PATCH', headers: { ...H, prefer: 'return=representation' }, body: JSON.stringify({ status: 'running', phase: 'claimed', updated_at: new Date().toISOString() }) }); + const rows = await r.json(); + return Array.isArray(rows) && rows.length > 0; +} +async function runOne(row) { + const argv = [join(ROOT, 'workers/supporters-build/run.mjs'), '--clinic', row.clinic_id, '--url', row.url, '--work', join(WORK_ROOT, `${row.clinic_id}-${row.id.slice(0, 8)}`), '--build-id', row.id, '--max-cost-usd', opt('max-cost-usd', '10')]; + if (row.clinic_name) argv.push('--name', row.clinic_name); + if (!flag('no-deploy')) argv.push('--deploy'); + console.log(`▶ ${row.clinic_id} ${row.url} (${row.id})`); + const r = spawnSync('node', argv, { cwd: ROOT, stdio: 'inherit', env: process.env }); + console.log(`${r.status === 0 ? '✓' : '✗'} ${row.clinic_id} exit ${r.status}`); +} +async function drain() { + let n = 0; + for (;;) { + const row = await nextQueued(); + if (!row) break; + if (!(await claim(row.id))) continue; + await runOne(row); n++; + } + return n; +} + +if (loopSec > 0) { + console.log(`큐 폴링 ${loopSec}초 간격 · ${SB}`); + for (;;) { try { const n = await drain(); if (n) console.log(`처리 ${n}건`); } catch (e) { console.error('폴링 오류', e.message); } await new Promise((r) => setTimeout(r, loopSec * 1000)); } +} else { + const n = await drain(); + console.log(n ? `처리 ${n}건` : '큐 비어 있음'); +} diff --git a/workers/supporters-build/run.mjs b/workers/supporters-build/run.mjs index 472ad8a..ddace4d 100644 --- a/workers/supporters-build/run.mjs +++ b/workers/supporters-build/run.mjs @@ -137,7 +137,7 @@ 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')); + argv.push('--max-cost-usd', opt('max-cost-usd', '10')); // v2 §9-5 haewon 결정: 병원당 한 번 실행 $10 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'));