feat(supporters): haewon 결정 반영 — 채널 인기 전체 탭 제거, 단가 상한 $10 기본, 큐 폴러(poll.mjs)+launchd, /supporters/:id 페이지, URL 신청→빌드 큐, 수집기 UA·슬래시 수정, 기획 목록 일반화(원진 6편)
WIP: 원진성형외과 회귀는 글 생성 단계에서 네트워크 끊김(fetch failed)으로 중단. 다음 세션에 --from generate 로 재실행. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
190ffdf7ab
commit
ee8c06f53b
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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(
|
||||
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
||||
<Route path="discovery" element={<DiscoveryLandingPage />} />
|
||||
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
||||
<Route path="supporters/:id" element={<SupportersBuildPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
32
src/pages/SupportersBuildPage.tsx
Normal file
32
src/pages/SupportersBuildPage.tsx
Normal file
@ -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 (
|
||||
<div className="[word-break:keep-all]">
|
||||
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 px-6">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
|
||||
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Supporters Build</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-3">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">Supporters Preview</span>
|
||||
</h1>
|
||||
<p className="text-purple-200 text-base">병원 ID <span className="font-mono text-white">{id}</span></p>
|
||||
<p className="text-white/60 text-sm mt-4">
|
||||
홈페이지 공개 자료만으로 만든 서포터즈 사이트 초안입니다. 아래에서 미리보기를 열고, 병원만 답할 수 있는 항목을 입력해 주세요.
|
||||
{' '}<Link to="/discovery" className="underline underline-offset-4 text-purple-300">AI Discovery 소개</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<ClinicInputsPanel clinicId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
supabase/migrations/20260907_supporter_builds_queue.sql
Normal file
4
supabase/migrations/20260907_supporter_builds_queue.sql
Normal file
@ -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');
|
||||
@ -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);
|
||||
|
||||
@ -21,7 +21,7 @@ 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 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;
|
||||
|
||||
@ -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 <id> --evidence <evidence/<id>> --site <siteDir> [--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(', ')}` : ''}`);
|
||||
|
||||
@ -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)));
|
||||
|
||||
|
||||
@ -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<string, any>;
|
||||
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 } } })) },
|
||||
];
|
||||
---
|
||||
<Base title={`의료진 영상 · ${clinic} 원장이 직접 설명하는 영상`} description={`${clinic} 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${fetchedAt} 기준.`} jsonLd={ld}>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -21,7 +21,7 @@ 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 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;
|
||||
|
||||
@ -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 <id> --evidence <evidence/<id>> --site <siteDir> [--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(', ')}` : ''}`);
|
||||
|
||||
@ -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)));
|
||||
|
||||
|
||||
@ -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<string, any>;
|
||||
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 } } })) },
|
||||
];
|
||||
---
|
||||
<Base title={`의료진 영상 · ${clinic} 원장이 직접 설명하는 영상`} description={`${clinic} 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${fetchedAt} 기준.`} jsonLd={ld}>
|
||||
|
||||
40
workers/supporters-build/README.md
Normal file
40
workers/supporters-build/README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 서포터즈 자동 빌드 워커
|
||||
|
||||
병원 홈페이지 URL 하나로 근거 수집 → 팩트 시트·의료진 초안 → 첫 배치 기획 → 글 생성(GPT-4.1, 게이트·근거 대조) → Astro 빌드·발행 게이트 → Vercel 미리보기(noindex) 까지 돈다. 사람 게이트(의학 검토·게시 승인)는 자동화하지 않는다.
|
||||
|
||||
```
|
||||
node workers/supporters-build/run.mjs --clinic <id> --url <https://...> [--name 상호] [--deploy] [--build-id <uuid>] [--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 <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-<id>` 를 만든다
|
||||
- `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 등)에 기대면 다른 병원에서 못 잡는다 → 제목·본문 키워드 분류를 보강할 것.
|
||||
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
서포터즈 빌드 큐 폴러 (5분마다). 설치:
|
||||
cp workers/supporters-build/launchd/com.infinith.supporters-poll.plist ~/Library/LaunchAgents/
|
||||
launchctl load ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
|
||||
해제:
|
||||
launchctl unload ~/Library/LaunchAgents/com.infinith.supporters-poll.plist
|
||||
로그: ~/supporters-builds/poll.log
|
||||
저장소 경로가 다르면 WorkingDirectory 와 ProgramArguments 의 경로를 바꾼다.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.infinith.supporters-poll</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/node</string>
|
||||
<string>workers/supporters-build/poll.mjs</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>/Users/haewonkam/orca/workspaces/INFINITH/Productize</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
<key>StartInterval</key><integer>300</integer>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/Users/haewonkam/supporters-builds/poll.log</string>
|
||||
<key>StandardErrorPath</key><string>/Users/haewonkam/supporters-builds/poll.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
62
workers/supporters-build/poll.mjs
Normal file
62
workers/supporters-build/poll.mjs
Normal file
@ -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}건` : '큐 비어 있음');
|
||||
}
|
||||
@ -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'));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user