// 수집 이미지 육안 검토 페이지를 만든다. // node scripts/build_image_review.mjs --out <파일.html> --site = [--site ...] // // 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진· // 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이 // 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고, // 제외할 파일명을 briefs//clinic.json 의 excludeImages 에 넣을 수 있게 목록으로 내준다. import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, resolve, dirname, basename } from 'node:path'; const args = process.argv.slice(2); const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; const sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []); const OUT = resolve(opt('out', 'image-review.html')); const ASSETS = join(dirname(OUT), 'image-review-files'); const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); // 파일명·alt 로 걸러지는 것만 미리 표시한다. 나머지는 사람이 본다. const FLAG = [ [/전후|비포|애프터|before|after|bna|b_a/i, '전후 사진 의심'], [/모델|model/i, '광고 모델 의심'], [/후기|리뷰|review|사례|case/i, '치료 경험담 의심'], [/수상|award|인증|certif/i, '인증·수상 의심'], [/vs|비교/i, '비교 광고 의심'], ]; const blocks = []; for (const spec of sites) { const [id, dir] = spec.split('='); const SITE = resolve(dir); const DATA = join(SITE, 'src', 'data'); if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, items: [], note: 'images.json 없음 (수집된 이미지 없음)' }); continue; } const manifest = readJson(join(DATA, 'images.json')); const site = existsSync(join(DATA, 'site.json')) ? readJson(join(DATA, 'site.json')) : {}; const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {}; // 화면 어디에 쓰이는지 const usage = new Map(); const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); }; mark(site.heroImage?.src, '홈 히어로'); mark(site.buildingImage?.src, '건물 사진'); for (const g of site.insideGallery ?? []) mark(g?.src, '내부 갤러리'); for (const g of site.clinicGallery ?? []) mark(g?.src, '병원 갤러리'); const POSTS = join(SITE, 'src', 'content', 'posts'); if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) { const t = readFileSync(join(POSTS, f), 'utf8'); for (const m of t.matchAll(/(?:thumbnail:|src:)\s*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 ${f.replace(/\.md$/, '')}`); } mkdirSync(join(ASSETS, id), { recursive: true }); const items = (manifest.items ?? []).map((it) => { const rel = it.src.replace(/^\//, ''); const from = join(SITE, 'public', rel); const name = `${it.category}__${basename(it.src)}`; if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name)); const hay = `${it.src} ${it.alt} ${it.page?.url ?? ''}`; return { ...it, name, flags: FLAG.filter(([re]) => re.test(hay)).map(([, label]) => label), used: usage.get(it.src) ?? [], }; }); blocks.push({ id, items, clinic: fact.shortName || id, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt }); } const style = ` :root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--ok:#1F7A4D;--bg:#fbfafd} *{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)} .wrap{max-width:1180px;margin:0 auto;padding:2.4rem 20px 4rem} h1{font-size:1.7rem;margin:0 0 .4rem}h2{font-size:1.25rem;margin:2.6rem 0 .3rem;padding-top:1.4rem;border-top:1px solid var(--line)} .lede{color:var(--muted);margin:0 0 1.6rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:1rem} .card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column} .card.flagged{border-color:var(--bad);box-shadow:0 0 0 2px rgba(179,38,30,.10)} .card img{width:100%;height:170px;object-fit:cover;background:#f1eff6;display:block} .meta{padding:.7rem .8rem .9rem;font-size:.8rem} .cat{display:inline-block;font-weight:700;font-size:.7rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} .fn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;word-break:break-all;color:var(--muted);margin:.25rem 0 .4rem} .alt{color:var(--ink);margin:.2rem 0} .use{margin-top:.45rem;font-size:.72rem;color:var(--ok)} .flag{margin-top:.45rem;font-size:.74rem;color:var(--bad);font-weight:700} .sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;margin:1rem 0} table{border-collapse:collapse;width:100%;font-size:.85rem}th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid var(--line)} code{background:#f1eff6;padding:.1rem .3rem;border-radius:4px;font-size:.85em} .none{color:var(--muted);padding:1rem 0} @media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:120px}} `; const body = blocks.map((b) => { if (!b.items.length) return `

${esc(b.clinic ?? b.id)}

${esc(b.note ?? '수집된 이미지 없음')}

`; const cards = b.items.map((it) => `
${esc(it.alt).slice(0, 120)}
${esc(it.category)}
${esc(basename(it.src))}
${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}
${it.used.length ? `
쓰이는 곳: ${esc(it.used.join(' · '))}
` : ''} ${it.flags.length ? `
${esc(it.flags.join(' · '))}
` : ''}
`).join(''); const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · '); return `

${esc(b.clinic ?? b.id)} ${esc(counts)} · ${esc(b.capturedAt ?? '')} 수집

${cards}
`; }).join('\n'); const html = ` 서포터즈 수집 이미지 육안 검토

서포터즈 수집 이미지 육안 검토

자동 수집이 분류한 이미지를 한 화면에 모았습니다. 빨간 테두리는 파일명·alt 로 걸러진 의심 항목이고, 그 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.

제외해야 하는 유형 (docs/INFINITH_Supporters_Image_Rules.md)

유형근거
전후 사진 · 경과 비교의료광고 심의 기준. 동일 조건 촬영·무보정·동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다
환자 · 광고 모델 신체 사진의료법 56조 2항 2호, 개인정보보호법·초상권
시술 행위 장면의료법 56조 2항 6호. 수술실 공간 사진은 시설로 허용됩니다
인증 · 수상 마크의료법 56조 2항 14호
타 병원 비교 표 · 그래프의료법 56조 2항 4호

제외할 파일은 supporters/briefs/<병원>/clinic.json 의 excludeImages 에 파일명 앞부분을 넣으면 다음 빌드부터 빠집니다.

${body}
`; mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, html); const flagged = blocks.reduce((n, b) => n + b.items.filter((i) => i.flags.length).length, 0); const total = blocks.reduce((n, b) => n + b.items.length, 0); console.log(`검토 페이지 → ${OUT} (이미지 ${total}장 · 자동 의심 ${flagged}장)`);