o2o-infinith-demo/supporters/scripts/build_image_review.mjs
Haewon Kam dd754cf212 feat(supporters): 글마다 다른 대표 이미지 배정 · 뷰 수집 이미지에서 의료광고 금지 유형 제외
haewon 지적: 목록 화면에 같은 사진이 나란히 선다.

원인은 PostCard 의 대체 순서다. thumbnail·hero·영상이 모두 없으면 건물 사진 하나로
수렴한다. 뷰는 5편 전부, 오라클은 8편 전부가 그랬고, 원진은 6편 중 3편이 같은
소개 커버를 쓰고 있었다.

heroes 단계를 워커에 넣었다(generate 다음, build 앞). scripts/assign_post_heroes.mjs 가
images.json 에서 글마다 다른 사진을 고른다.
- 글 분류별로 어울리는 유형을 먼저 쓴다. 병원 소개·방문은 시설, 시술 정보는 시술 설명
  일러스트, 안전은 장비 순이다. 방문 안내에 시술 일러스트가 붙는 것을 막는다.
- 의료진 사진은 쓰지 않는다. 저자·검토자 표시가 지정 용도이고, 글 대표로 쓰면 그 원장이
  쓴 글로 읽힌다.
- 홈 히어로·건물 사진과 겹치는 글도 다시 배정한다.
- 수집 원문 alt 를 그대로 쓰지 않는다. 원문은 술기 설명 문단이라 고객 화면에 낼 수 없어
  첫 구절만 줄여 쓴다. 캡션의 출처도 페이지 제목이 슬로건일 때 상호로 바꾼다.
- 이미지가 글보다 적으면 남는 글은 비워 둔다. 다시 쓰면 처음 문제로 돌아간다.

작업 중 더 큰 문제를 찾았다. 뷰의 수집 이미지 중 다섯 장이 의료광고 금지 유형인데
전부 시설(clinic)로 분류돼 홈과 /clinic 에 나가고 있었다.
- img02: 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진
- stemcell_about_01: 팔에 주사·드레싱을 하는 시술 행위 사진
- view-abdominoplasty 2장, view-motiva-preserve-premium-standard 2장: "이미지 광고 모델" 표기가 있는 모델 신체 사진
- stemcell_count_01_mo: 장비 제조사의 성능 주장 그래픽
파일명과 alt 에 아무 신호가 없어 규칙 문서가 예고한 파일명·alt 검사로는 잡히지 않는다.
briefs/viewclinic/clinic.json 의 excludeImages 로 뺐고, 홈 히어로가 가슴 라인
일러스트였던 것과 건물 사진 alt 가 "가슴확대 시설 사진"이던 것도 함께 고정했다.

육안 검토용 scripts/build_image_review.mjs 를 만들었다. 세 병원 수집 이미지를 한 화면에
모아 분류·쓰이는 곳·의심 표시를 보여준다. 91장 중 파일명·alt 로 걸러진 것은 0장이었다.
이 유형은 사람이 봐야 갈린다는 뜻이다.

결과: 홈·목록의 연속 중복이 뷰 1, 원진 1 로 내려갔다(이전 뷰 6, 원진 3).
오라클은 수집 이미지가 0장이라 배정할 것이 없다. 근거의 이미지 alt 가 EUC-KR 에서
깨져(????) 분류기가 하나도 고르지 못하는 것이 원인으로 좁혀졌으나, 병원 사이트가
응답하지 않아 재수집·검증을 하지 못했다.

검증: 게이트 41/41, check.mjs 오류 0건, tsc 0 에러, 내보내기 종료코드 0. 모두 미배포.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 16:15:52 +09:00

132 lines
8.6 KiB
JavaScript

// 수집 이미지 육안 검토 페이지를 만든다.
// node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...]
//
// 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진·
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이
// 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고,
// 제외할 파일명을 briefs/<clinic>/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 `<h2>${esc(b.clinic ?? b.id)}</h2><p class="none">${esc(b.note ?? '수집된 이미지 없음')}</p>`;
const cards = b.items.map((it) => `
<figure class="card${it.flags.length ? ' flagged' : ''}">
<img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(it.alt).slice(0, 120)}" loading="lazy">
<figcaption class="meta">
<span class="cat">${esc(it.category)}</span>
<div class="fn">${esc(basename(it.src))}</div>
<div class="alt">${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}</div>
${it.used.length ? `<div class="use">쓰이는 곳: ${esc(it.used.join(' · '))}</div>` : ''}
${it.flags.length ? `<div class="flag">${esc(it.flags.join(' · '))}</div>` : ''}
</figcaption>
</figure>`).join('');
const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · ');
return `<h2>${esc(b.clinic ?? b.id)} <span style="font-weight:400;color:var(--muted);font-size:.9rem">${esc(counts)} · ${esc(b.capturedAt ?? '')} 수집</span></h2>
<div class="grid">${cards}</div>`;
}).join('\n');
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>서포터즈 수집 이미지 육안 검토</title><style>${style}</style></head><body><div class="wrap">
<h1>서포터즈 수집 이미지 육안 검토</h1>
<p class="lede">자동 수집이 분류한 이미지를 한 화면에 모았습니다. 빨간 테두리는 파일명·alt 로 걸러진 의심 항목이고, 그 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.</p>
<div class="sum">
<p style="margin:.2rem 0 .7rem"><strong>제외해야 하는 유형</strong> (docs/INFINITH_Supporters_Image_Rules.md)</p>
<table>
<tr><th>유형</th><th>근거</th></tr>
<tr><td>전후 사진 · 경과 비교</td><td>의료광고 심의 기준. 동일 조건 촬영·무보정·동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다</td></tr>
<tr><td>환자 · 광고 모델 신체 사진</td><td>의료법 56조 2항 2호, 개인정보보호법·초상권</td></tr>
<tr><td>시술 행위 장면</td><td>의료법 56조 2항 6호. 수술실 공간 사진은 시설로 허용됩니다</td></tr>
<tr><td>인증 · 수상 마크</td><td>의료법 56조 2항 14호</td></tr>
<tr><td>타 병원 비교 표 · 그래프</td><td>의료법 56조 2항 4호</td></tr>
</table>
<p style="margin:.9rem 0 0;color:var(--muted)">제외할 파일은 <code>supporters/briefs/&lt;병원&gt;/clinic.json</code> 의 <code>excludeImages</code> 에 파일명 앞부분을 넣으면 다음 빌드부터 빠집니다.</p>
</div>
${body}
</div></body></html>`;
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}장)`);