Compare commits

..

2 Commits

Author SHA1 Message Date
Haewon Kam
8f90bbb7cd docs·tools: 이미지 육안 검토 요청서 + 핸드오버 §5-3 (사람 게이트 근거·오라클 원인 확정)
build_image_review.mjs 를 검토 요청서로 다시 만들었다. 화면에 실제로 나가는 이미지를
먼저 보여주고(노란 테두리), 빼야 하는 유형을 무엇이 어떻게 생겼는지로 설명하며,
체크하면 excludeImages 에 넣을 목록이 만들어진다. 판단이 서지 않으면 빼는 쪽으로
보라고 적었다. 뷰 17장·원진 66장·오라클 0장, 그중 19장이 화면에 나간다.

핸드오버 §5-3 신설. 파일명·alt 검사로 91장 중 0장이 걸린 것을 근거로 이미지 육안 확인을
사람 게이트로 남긴다고 적었다. 규칙 문서 §4 의 IMAGE_RULE 계획은 이 유형에 효과가 없다.

오라클 원인 확정: 근거 수집일 2026-09-10 이 EUC-KR 디코딩 반영일 2026-09-11 보다
하루 빨라 옛 수집기가 alt 를 깨뜨렸다. 코드는 이미 고쳐져 있어 재수집이면 풀리지만
병원 사이트가 응답하지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 16:50:14 +09:00
Haewon Kam
f75a0575db chore(supporters): 뷰 랜딩에서 가슴 라인 일러스트 제외 (haewon 지적)
view-motiva-preserve-motiva-smooth-silk 는 규칙상 시술 설명 일러스트로 허용되는
유형이지만, 랜딩의 '상담 전 질문' 카드 썸네일로 붙어 주제가 어긋나고 첫 화면에
신체 그림이 노출됐다. 홈 히어로는 이미 건물 외관으로 바꿔 둔 상태였다.

빼고 나니 뷰에서 글 대표로 쓸 수 있는 이미지가 4장(건물·의료진 단체·일러스트 2)뿐이라
5편 중 2편이 건물 사진으로 떨어진다. 홈 기준 최대 연속 2다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 16:35:13 +09:00
3 changed files with 156 additions and 74 deletions

View File

@ -121,6 +121,38 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI
- **템플릿에 넣기 전 빈 데이터로 빌드해 본다.** 한 병원의 데이터로만 확인하면 하드코딩과 데이터 참조가 구별되지 않는다. 화면이 같아 보이기 때문이다.
- **게이트가 통과해도 화면은 깨질 수 있다.** 게이트는 의료광고 문구와 금칙어를 보지, 빈 값이 만든 비문을 보지 않는다. 배포 전 로컬 확인이 필요한 이유다.
## 5-3. 이미지 (2026-09-12) · 사람 게이트와 남은 일
### 자동 분류를 믿을 수 없다
뷰성형외과 수집 이미지 12장 중 6장이 의료광고 금지 유형인데 전부 시설(clinic)로 분류돼 있었다.
전후 사진(밑선 절개 흉터 1개월·6개월 경과), 시술 행위 사진(팔 주사·드레싱), 광고 모델 신체 사진 4장,
장비 제조사 성능 주장 그래픽, 가슴 라인 일러스트(랜딩 카드 썸네일로 노출)다.
**파일명·alt 로는 한 장도 걸리지 않는다.** 91장을 그 방식으로 검사해 0장이 걸렸다.
파일명이 `img02` 이고 alt 가 "가슴확대 시설 사진" 이면 어떤 규칙도 전후 사진임을 알 수 없다.
규칙 문서 §4 의 `IMAGE_RULE`(파일명·alt 검사) 계획은 이 유형에 효과가 없다.
→ **이미지 육안 확인은 사람 게이트로 남긴다.** `scripts/build_image_review.mjs` 가 검토 요청서를 만든다.
화면에 실제로 나가는 것을 먼저 보여주고, 체크하면 `excludeImages` 에 넣을 목록이 만들어진다.
`node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir>`
### 대표 이미지 배정 (워커 heroes 단계)
`PostCard` 의 대체 순서가 건물 사진 하나로 수렴해 목록에 같은 사진이 나란히 섰다.
`scripts/assign_post_heroes.mjs` 가 글 분류별로 어울리는 유형을 골라 글마다 다른 사진을 배정한다.
의료진 사진은 쓰지 않고(저자·검토자 표시가 지정 용도), 수집 alt 가 효과·결과를 주장하면 중립 문구로 바꾸며,
이미지가 모자라면 다시 쓰지 않고 비운다.
### 남은 일
| 항목 | 상태 |
|---|---|
| 원진 66장 육안 검토 | **요청 상태.** 그중 15장이 화면에 나간다. 아직 아무도 보지 않았다 |
| 뷰 17장 | 12장 확인 완료(6장 제외). 의료진 13장은 검토자 표시 용도라 별도 |
| 오라클 이미지 | **0장.** 근거 수집일 2026-09-10 이 EUC-KR 디코딩(2026-09-11 반영)보다 하루 빨라 `images[].alt` 가 전부 `????` 로 깨졌고 분류기가 한 장도 고르지 못한다. **코드는 이미 고쳐져 있으므로 `--from evidence` 재수집이면 풀린다.** 다만 `oracleclinic.com` 이 응답하지 않는다(포트 80 연결 거부, 2026-09-12 기준). 그룹 사이트는 살아 있으나 Next.js 이미지 최적화 뒤에 있고 내용이 브랜드 배너·인증 마크라 대부분 제외 대상이다 |
| 뷰 이미지 부족 | 제외 후 쓸 수 있는 것이 4장이라 글 5편 중 2편이 건물 사진으로 떨어진다. 병원 제공 시설 사진이 필요하다 |
## 6. 함정 (제품 관점)
- **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만.

View File

@ -13,9 +13,10 @@
"stemcell_about_01",
"stemcell_count_01_mo",
"view-abdominoplasty",
"view-motiva-preserve-premium-standard"
"view-motiva-preserve-premium-standard",
"view-motiva-preserve-motiva-smooth-silk"
],
"_excludeNote": "2026-09-12 육안 확인으로 제외. img02 는 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진이고, stemcell_about_01 은 팔에 주사·드레싱을 하는 시술 행위 사진이며, view-abdominoplasty 두 장과 view-motiva-preserve-premium-standard 두 장은 '이미지 광고 모델' 표기가 있는 모델 신체 사진, stemcell_count_01_mo 는 장비 제조사의 성능 주장 그래픽이다. 파일명과 alt 에 신호가 없어 자동 분류가 전부 시설(clinic)로 넣었다.",
"_excludeNote": "2026-09-12 육안 확인으로 제외. img02 는 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진이고, stemcell_about_01 은 팔에 주사·드레싱을 하는 시술 행위 사진이며, view-abdominoplasty 두 장과 view-motiva-preserve-premium-standard 두 장은 '이미지 광고 모델' 표기가 있는 모델 신체 사진, stemcell_count_01_mo 는 장비 제조사의 성능 주장 그래픽이다. 파일명과 alt 에 신호가 없어 자동 분류가 전부 시설(clinic)로 넣었다. 2026-09-12 추가: view-motiva-preserve-motiva-smooth-silk 는 가슴·보형물 라인 일러스트다. 규칙상 시술 설명 일러스트로 허용되는 유형이지만 랜딩의 '상담 전 질문' 카드 썸네일로 붙어 주제가 어긋나고 첫 화면에 신체 그림이 노출된다(haewon 지적).",
"site": {
"heroImage": {
"srcMatch": "view-building_pc",

View File

@ -1,10 +1,11 @@
// 수집 이미지 육안 검토 페이지를 만든다.
// 수집 이미지 육안 검토 요청서를 만든다.
// 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 에 넣을 수 있게 목록으로 내준다.
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 2026-09-12 뷰성형외과에서 91장을
// 파일명·alt 로 검사했을 때 걸린 것이 0장이었고, 눈으로 보니 12장 중 6장이 금지 유형이었다.
// 그래서 이 단계는 사람 게이트로 둔다. 이 페이지는 그 판정을 한 화면에서 하고,
// 결과를 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있는 형태로 돌려준다.
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
@ -13,29 +14,26 @@ const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[
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 ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..', '..');
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, '비교 광고 의심'],
];
// 파일명에서 수집기가 붙인 해시를 떼어 excludeImages 에 쓸 접두어를 만든다.
const prefixOf = (src) => basename(src).replace(/-[0-9a-f]{10}\.\w+$/, '').replace(/\.\w+$/, '');
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 fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
const briefPath = join(ROOT, 'supporters', 'briefs', id, 'clinic.json');
const already = existsSync(briefPath) ? (readJson(briefPath).excludeImages ?? []) : [];
if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, clinic: fact.shortName || id, items: [], already, note: '수집된 이미지가 없습니다. 검토할 것이 없습니다.' }); 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, '홈 히어로');
@ -45,87 +43,138 @@ for (const spec of sites) {
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$/, '')}`);
for (const m of t.matchAll(/(?:thumbnail:|src:)[ \t]*["']?(\/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 from = join(SITE, 'public', it.src.replace(/^\//, ''));
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 });
return { ...it, name, prefix: prefixOf(it.src), used: usage.get(it.src) ?? [] };
}).sort((a, b) => (b.used.length > 0) - (a.used.length > 0) || a.category.localeCompare(b.category) || a.src.localeCompare(b.src));
blocks.push({ id, clinic: fact.shortName || id, items, already, 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}
:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--live:#7A4B00;--bg:#fbfafd;--accent:#4F1DA1}
*{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);word-break:keep-all}
.wrap{max-width:1200px;margin:0 auto;padding:2.4rem 20px 5rem}
h1{font-size:1.75rem;margin:0 0 .5rem}h2{font-size:1.3rem;margin:2.8rem 0 .2rem;padding-top:1.5rem;border-top:1px solid var(--line)}
.lede{color:var(--muted);margin:0 0 1.5rem;max-width:70ch}
.ask{background:#fff;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:10px;padding:1rem 1.2rem;margin:1.2rem 0 1.8rem}
.ask p{margin:.3rem 0}
.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin:1.2rem 0}
table{border-collapse:collapse;width:100%;font-size:.87rem}th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);vertical-align:top}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:1rem;margin-top:1rem}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
.card.live{border-color:#E0B000;box-shadow:0 0 0 2px rgba(224,176,0,.16)}
.card.out{opacity:.45}
.card img{width:100%;height:180px;object-fit:contain;background:#f1eff6;display:block}
.meta{padding:.7rem .8rem .9rem;font-size:.8rem;flex:1;display:flex;flex-direction:column;gap:.3rem}
.cat{font-weight:700;font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
.fn{font-family:ui-monospace,Menlo,monospace;font-size:.7rem;word-break:break-all;color:var(--muted)}
.alt{color:var(--ink)}
.use{font-size:.72rem;color:var(--live);font-weight:700}
.pick{display:flex;gap:.4rem;align-items:center;padding:.55rem .8rem;border-top:1px solid var(--line);background:#fdfcff;font-size:.85rem;cursor:pointer}
.pick input{width:16px;height:16px;accent-color:var(--bad);cursor:pointer}
.out-box{position:sticky;bottom:0;background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin-top:1.6rem;box-shadow:0 -4px 18px rgba(29,0,36,.07)}
textarea{width:100%;min-height:96px;font-family:ui-monospace,Menlo,monospace;font-size:.78rem;border:1px solid var(--line);border-radius:8px;padding:.6rem;background:#fbfafd;color:var(--ink)}
button{font:inherit;font-size:.85rem;padding:.45rem .9rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer}
code{background:#f1eff6;padding:.1rem .35rem;border-radius:4px;font-size:.86em}
.badge{display:inline-block;font-size:.7rem;font-weight:700;padding:.1rem .4rem;border-radius:5px;background:#f1eff6;color:var(--muted);margin-left:.3rem}
.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}}
@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:130px}}
`;
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 head = `<h2>${esc(b.clinic)} <span style="font-weight:400;color:var(--muted);font-size:.88rem">${esc(Object.entries(b.counts ?? {}).map(([k, v]) => `${k} ${v}`).join(' · '))}${b.capturedAt ? ` · ${esc(b.capturedAt)} 수집` : ''}</span></h2>`;
if (!b.items.length) return `${head}<p class="none">${esc(b.note ?? '수집된 이미지가 없습니다.')}</p>`;
const liveCount = b.items.filter((i) => i.used.length).length;
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">
<figure class="card${it.used.length ? ' live' : ''}" data-prefix="${esc(it.prefix)}">
<img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(String(it.alt).slice(0, 120))}" loading="lazy">
<figcaption class="meta">
<span class="cat">${esc(it.category)}</span>
<span class="cat">${esc(it.category)}${it.used.length ? '<span class="badge">화면에 노출</span>' : ''}</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>` : ''}
<div class="alt">${esc(String(it.alt).slice(0, 100))}${String(it.alt).length > 100 ? '…' : ''}</div>
${it.used.length ? `<div class="use">${esc(it.used.join(' · '))}</div>` : ''}
</figcaption>
<label class="pick"><input type="checkbox" data-clinic="${esc(b.id)}" data-prefix="${esc(it.prefix)}"> 이 이미지 빼기</label>
</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>
const alreadyRow = b.already.length
? `<p style="margin:.6rem 0 0;color:var(--muted);font-size:.85rem">이미 빠져 있는 것 ${b.already.length}건: <code>${esc(b.already.join('</code>, <code>'))}</code></p>`
: '';
return `${head}
<p style="margin:.4rem 0 0;color:var(--muted)">${b.items.length}장 · 이 중 <strong style="color:var(--live)">${liveCount}장이 지금 화면에 나갑니다</strong>(노란 테두리). 이것부터 보시면 됩니다.${alreadyRow ? '' : ''}</p>
${alreadyRow}
<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>
<title>서포터즈 수집 이미지 육안 검토 요청</title><style>${style}</style></head><body><div class="wrap">
<h1>서포터즈 수집 이미지 육안 검토 요청</h1>
<p class="lede">병원 홈페이지에서 자동으로 모은 이미지입니다. 어떤 이미지를 빼야 하는지는 파일명이나 설명문으로 가려지지 않아 눈으로 보셔야 합니다.</p>
<div class="ask">
<p><strong>부탁드리는 것</strong></p>
<p>아래 표의 유형에 해당하는 이미지를 찾아 카드 아래 <strong>"이 이미지 빼기"</strong>를 체크해 주세요. 다 고르시면 맨 아래 상자에 제외 목록이 만들어집니다. 그 값을 알려주시면 다음 빌드부터 빠집니다.</p>
<p><strong>노란 테두리가 지금 화면에 나가는 이미지</strong>입니다. 시간이 없으시면 그것만 보셔도 됩니다.</p>
</div>
<div class="sum">
<p style="margin:.2rem 0 .7rem"><strong>빼야 하는 유형</strong> · 근거 <code>docs/INFINITH_Supporters_Image_Rules.md</code></p>
<table>
<tr><th style="width:22%">유형</th><th style="width:34%">어떻게 생겼나</th><th>근거</th></tr>
<tr><td>전후 사진 · 경과 비교</td><td>같은 부위를 두 시점으로 나란히 놓은 사진. "수술 후 1개월 / 6개월" 같은 글자가 박혀 있기도 합니다</td><td>의료광고 심의 기준. 동일 조건 촬영·무보정·환자 동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다</td></tr>
<tr><td>환자 · 광고 모델 신체 사진</td><td>얼굴이나 몸이 나오는 인물 사진. 구석에 "이미지 광고 모델" 표기가 있는 경우가 많습니다</td><td>의료법 56조 2항 2호 · 개인정보보호법 · 초상권</td></tr>
<tr><td>시술 행위 장면</td><td>주사·절개·드레싱처럼 시술하는 손과 환자 신체가 함께 나오는 사진. 수술실 공간만 찍은 사진은 괜찮습니다</td><td>의료법 56조 2항 6호</td></tr>
<tr><td>효과 · 성능 주장 그래픽</td><td>"개선", "재생", 숫자와 화살표로 성능을 내세운 제조사 홍보 이미지</td><td>의료법 56조 2항 8호</td></tr>
<tr><td>인증 · 수상 마크</td><td>상장, 어워드 로고, 인증 배지</td><td>의료법 56조 2항 14호</td></tr>
<tr><td>타 병원 비교</td><td>다른 병원과 견주는 표나 그래프</td><td>의료법 56조 2항 4호</td></tr>
</table>
<p style="margin:.9rem 0 0;color:var(--muted)">판단이 서지 않으면 빼는 쪽으로 봐 주세요. 뺀 이미지는 병원이 조건을 서면으로 확인해 주면 다시 넣을 수 있습니다.</p>
</div>
${body}
</div></body></html>`;
<div class="out-box">
<p style="margin:.1rem 0 .5rem"><strong>제외 목록</strong> <span style="color:var(--muted);font-size:.85rem">체크하면 여기에 쌓입니다. 이 내용을 그대로 알려주시면 됩니다.</span></p>
<textarea id="out" readonly placeholder="아직 고른 것이 없습니다."></textarea>
<p style="margin:.6rem 0 0"><button id="copy">복사</button> <span id="msg" style="color:var(--muted);font-size:.85rem;margin-left:.5rem"></span></p>
</div>
</div>
<script>
(function () {
var out = document.getElementById('out'), msg = document.getElementById('msg');
function refresh() {
var by = {};
document.querySelectorAll('input[type=checkbox]').forEach(function (c) {
var card = c.closest('.card');
if (card) card.classList.toggle('out', c.checked);
if (!c.checked) return;
(by[c.dataset.clinic] = by[c.dataset.clinic] || []).push(c.dataset.prefix);
});
var keys = Object.keys(by);
out.value = keys.length ? keys.map(function (k) {
return k + ': ' + JSON.stringify(by[k].filter(function (v, i, a) { return a.indexOf(v) === i; }));
}).join('\\n') : '';
}
document.addEventListener('change', function (e) { if (e.target.type === 'checkbox') refresh(); });
document.getElementById('copy').addEventListener('click', function () {
if (!out.value) { msg.textContent = '고른 것이 없습니다.'; return; }
out.select();
try { document.execCommand('copy'); msg.textContent = '복사했습니다.'; }
catch (e) { msg.textContent = '직접 선택해 복사해 주세요.'; }
});
})();
</script>
</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}장)`);
const live = blocks.reduce((n, b) => n + b.items.filter((i) => i.used.length).length, 0);
console.log(`검토 요청서 → ${OUT}`);
for (const b of blocks) console.log(` ${b.clinic}: ${b.items.length}장 (화면 노출 ${b.items.filter((i) => i.used.length).length}장 · 이미 제외 ${b.already.length}건)`);
console.log(`합계 ${total}장 · 화면 노출 ${live}장`);