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>
This commit is contained in:
Haewon Kam 2026-09-12 16:50:14 +09:00
parent f75a0575db
commit 8f90bbb7cd
2 changed files with 153 additions and 72 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. 함정 (제품 관점) ## 6. 함정 (제품 관점)
- **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만. - **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만.

View File

@ -1,10 +1,11 @@
// 수집 이미지 육안 검토 페이지를 만든다. // 수집 이미지 육안 검토 요청서를 만든다.
// node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...] // node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...]
// //
// 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진· // 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진·
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이 // 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 2026-09-12 뷰성형외과에서 91장을
// 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고, // 파일명·alt 로 검사했을 때 걸린 것이 0장이었고, 눈으로 보니 12장 중 6장이 금지 유형이었다.
// 제외할 파일명을 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있게 목록으로 내준다. // 그래서 이 단계는 사람 게이트로 둔다. 이 페이지는 그 판정을 한 화면에서 하고,
// 결과를 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있는 형태로 돌려준다.
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path'; 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 sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []);
const OUT = resolve(opt('out', 'image-review.html')); const OUT = resolve(opt('out', 'image-review.html'));
const ASSETS = join(dirname(OUT), 'image-review-files'); 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 readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// 파일명에서 수집기가 붙인 해시를 떼어 excludeImages 에 쓸 접두어를 만든다.
// 파일명·alt 로 걸러지는 것만 미리 표시한다. 나머지는 사람이 본다. const prefixOf = (src) => basename(src).replace(/-[0-9a-f]{10}\.\w+$/, '').replace(/\.\w+$/, '');
const FLAG = [
[/전후|비포|애프터|before|after|bna|b_a/i, '전후 사진 의심'],
[/모델|model/i, '광고 모델 의심'],
[/후기|리뷰|review|사례|case/i, '치료 경험담 의심'],
[/수상|award|인증|certif/i, '인증·수상 의심'],
[/vs|비교/i, '비교 광고 의심'],
];
const blocks = []; const blocks = [];
for (const spec of sites) { for (const spec of sites) {
const [id, dir] = spec.split('='); const [id, dir] = spec.split('=');
const SITE = resolve(dir); const SITE = resolve(dir);
const DATA = join(SITE, 'src', 'data'); 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 manifest = readJson(join(DATA, 'images.json'));
const site = existsSync(join(DATA, 'site.json')) ? readJson(join(DATA, 'site.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 usage = new Map();
const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); }; const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); };
mark(site.heroImage?.src, '홈 히어로'); mark(site.heroImage?.src, '홈 히어로');
@ -45,87 +43,138 @@ for (const spec of sites) {
const POSTS = join(SITE, 'src', 'content', 'posts'); const POSTS = join(SITE, 'src', 'content', 'posts');
if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) { if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const t = readFileSync(join(POSTS, f), 'utf8'); 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 }); mkdirSync(join(ASSETS, id), { recursive: true });
const items = (manifest.items ?? []).map((it) => { const items = (manifest.items ?? []).map((it) => {
const rel = it.src.replace(/^\//, ''); const from = join(SITE, 'public', it.src.replace(/^\//, ''));
const from = join(SITE, 'public', rel);
const name = `${it.category}__${basename(it.src)}`; const name = `${it.category}__${basename(it.src)}`;
if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name)); if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name));
const hay = `${it.src} ${it.alt} ${it.page?.url ?? ''}`; return { ...it, name, prefix: prefixOf(it.src), used: usage.get(it.src) ?? [] };
return { }).sort((a, b) => (b.used.length > 0) - (a.used.length > 0) || a.category.localeCompare(b.category) || a.src.localeCompare(b.src));
...it, name, blocks.push({ id, clinic: fact.shortName || id, items, already, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt });
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 = ` const style = `
:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--ok:#1F7A4D;--bg:#fbfafd} :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)} *{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:1180px;margin:0 auto;padding:2.4rem 20px 4rem} .wrap{max-width:1200px;margin:0 auto;padding:2.4rem 20px 5rem}
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)} 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.6rem} .lede{color:var(--muted);margin:0 0 1.5rem;max-width:70ch}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:1rem} .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}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column} .ask p{margin:.3rem 0}
.card.flagged{border-color:var(--bad);box-shadow:0 0 0 2px rgba(179,38,30,.10)} .sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin:1.2rem 0}
.card img{width:100%;height:170px;object-fit:cover;background:#f1eff6;display:block} 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}
.meta{padding:.7rem .8rem .9rem;font-size:.8rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:1rem;margin-top:1rem}
.cat{display:inline-block;font-weight:700;font-size:.7rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} .card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
.fn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;word-break:break-all;color:var(--muted);margin:.25rem 0 .4rem} .card.live{border-color:#E0B000;box-shadow:0 0 0 2px rgba(224,176,0,.16)}
.alt{color:var(--ink);margin:.2rem 0} .card.out{opacity:.45}
.use{margin-top:.45rem;font-size:.72rem;color:var(--ok)} .card img{width:100%;height:180px;object-fit:contain;background:#f1eff6;display:block}
.flag{margin-top:.45rem;font-size:.74rem;color:var(--bad);font-weight:700} .meta{padding:.7rem .8rem .9rem;font-size:.8rem;flex:1;display:flex;flex-direction:column;gap:.3rem}
.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;margin:1rem 0} .cat{font-weight:700;font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
table{border-collapse:collapse;width:100%;font-size:.85rem}th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid var(--line)} .fn{font-family:ui-monospace,Menlo,monospace;font-size:.7rem;word-break:break-all;color:var(--muted)}
code{background:#f1eff6;padding:.1rem .3rem;border-radius:4px;font-size:.85em} .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} .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) => { 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) => ` const cards = b.items.map((it) => `
<figure class="card${it.flags.length ? ' flagged' : ''}"> <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(it.alt).slice(0, 120)}" loading="lazy"> <img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(String(it.alt).slice(0, 120))}" loading="lazy">
<figcaption class="meta"> <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="fn">${esc(basename(it.src))}</div>
<div class="alt">${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}</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>` : ''} ${it.used.length ? `<div class="use">${esc(it.used.join(' · '))}</div>` : ''}
${it.flags.length ? `<div class="flag">${esc(it.flags.join(' · '))}</div>` : ''}
</figcaption> </figcaption>
<label class="pick"><input type="checkbox" data-clinic="${esc(b.id)}" data-prefix="${esc(it.prefix)}"> 이미지 빼기</label>
</figure>`).join(''); </figure>`).join('');
const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · '); const alreadyRow = b.already.length
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> ? `<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>`; <div class="grid">${cards}</div>`;
}).join('\n'); }).join('\n');
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 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"> <title>서포터즈 수집 이미지 육안 검토 요청</title><style>${style}</style></head><body><div class="wrap">
<h1>서포터즈 수집 이미지 육안 검토</h1> <h1>서포터즈 수집 이미지 육안 검토 요청</h1>
<p class="lede">자동 수집이 분류한 이미지를 화면에 모았습니다. 빨간 테두리는 파일명·alt 걸러진 의심 항목이고, 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.</p> <p class="lede">병원 홈페이지에서 자동으로 모은 이미지입니다. 어떤 이미지를 빼야 하는지는 파일명이나 설명문으로 가려지지 않아 눈으로 보셔야 합니다.</p>
<div class="sum">
<p style="margin:.2rem 0 .7rem"><strong>제외해야 하는 유형</strong> (docs/INFINITH_Supporters_Image_Rules.md)</p> <div class="ask">
<table> <p><strong>부탁드리는 </strong></p>
<tr><th>유형</th><th></th></tr> <p>아래 표의 유형에 해당하는 이미지를 찾아 카드 아래 <strong>"이 이미지 빼기"</strong> . . .</p>
<tr><td>전후 사진 · 경과 비교</td><td> . ··· </td></tr> <p><strong>노란 테두리가 지금 화면에 나가는 이미지</strong>. .</p>
<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> </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} ${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 }); mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, html); 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); 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}`);