feat(supporters): 원진 랜딩 히어로를 글로벌 허브 리셉션 사진으로 (haewon 지정) · 갤러리 사진을 카드 후보에서 제외

haewon 육안 검토 결과: 원진 수집 이미지 66장 전부 사용 가능, 제외할 것 없음.
랜딩 히어로는 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 지정.
이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다.
alt 는 수집 원문("WJ 원진 소개 커버 이미지") 대신 무엇을 찍은 것인지 밝히는 문구로 쓴다.

갤러리에 실린 사진은 글 카드 후보에서 뺀다. 홈에서 글 목록 바로 아래가 갤러리라
카드 마지막 장과 갤러리 첫 장이 같으면 나란히 두 번 보인다.
다만 이렇게 빼서 글 수를 못 채우면 남는 글이 건물 사진으로 떨어져 더 나빠지므로,
후보가 글 수를 채울 수 있을 때만 뺀다.

결과
- 원진: 홈 13장 / 12종 · 최대 연속 1 (이전 9종 · 연속 2)
- 뷰: 홈 8장 / 4종 · 최대 연속 2. 쓸 수 있는 이미지가 3장뿐이라(건물 사진은 히어로)
  5편 중 2편이 건물 사진으로 떨어진다. 병원 제공 시설 사진이 있어야 풀린다.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-12 16:55:40 +09:00
parent 8f90bbb7cd
commit 85a9aa1f54
6 changed files with 158 additions and 82 deletions

View File

@ -8,5 +8,15 @@
"shortName": "원진성형외과", "shortName": "원진성형외과",
"kind": "성형외과" "kind": "성형외과"
}, },
"_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다." "_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다.",
"site": {
"heroImage": {
"srcMatch": "img_about_cover_03",
"alt": "원진성형외과 글로벌 허브 리셉션과 대기 공간",
"width": 1600,
"height": 633
}
},
"_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.",
"_imageReviewNote": "2026-09-12 haewon 육안 검토: 원진 수집 이미지 66장 전부 사용 가능. 제외할 것 없음."
} }

View File

@ -83,9 +83,12 @@ const CATS = new Set(['procedure', 'equipment', 'clinic']);
// 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면 // 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면
// 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로 // 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로
// 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다. // 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다.
const pool = (manifest.items ?? []) const base = (manifest.items ?? []).filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src));
.filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src)) // 갤러리에 실린 사진은 뺀다. 홈에서 글 목록 바로 아래가 갤러리라, 카드 마지막 장과 갤러리 첫 장이
.map((i) => ({ ...i, inGallery: inGallery.has(i.src) })); // 같으면 나란히 두 번 보인다. 다만 이렇게 빼서 글 수를 못 채우면 더 나빠진다. 못 채운 글은 건물 사진으로
// 떨어져 같은 사진이 여러 장 생기기 때문이다. 그래서 전부 채울 수 있을 때만 뺀다.
const notInGallery = base.filter((i) => !inGallery.has(i.src));
const pool = (notInGallery.length >= targets.length ? notInGallery : base).map((i) => ({ ...i, inGallery: inGallery.has(i.src) }));
if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); } if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); }
if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); } if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); }

View File

@ -13,9 +13,10 @@
"stemcell_about_01", "stemcell_about_01",
"stemcell_count_01_mo", "stemcell_count_01_mo",
"view-abdominoplasty", "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": { "site": {
"heroImage": { "heroImage": {
"srcMatch": "view-building_pc", "srcMatch": "view-building_pc",

View File

@ -8,5 +8,15 @@
"shortName": "원진성형외과", "shortName": "원진성형외과",
"kind": "성형외과" "kind": "성형외과"
}, },
"_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다." "_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다.",
"site": {
"heroImage": {
"srcMatch": "img_about_cover_03",
"alt": "원진성형외과 글로벌 허브 리셉션과 대기 공간",
"width": 1600,
"height": 633
}
},
"_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.",
"_imageReviewNote": "2026-09-12 haewon 육안 검토: 원진 수집 이미지 66장 전부 사용 가능. 제외할 것 없음."
} }

View File

@ -83,9 +83,12 @@ const CATS = new Set(['procedure', 'equipment', 'clinic']);
// 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면 // 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면
// 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로 // 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로
// 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다. // 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다.
const pool = (manifest.items ?? []) const base = (manifest.items ?? []).filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src));
.filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src)) // 갤러리에 실린 사진은 뺀다. 홈에서 글 목록 바로 아래가 갤러리라, 카드 마지막 장과 갤러리 첫 장이
.map((i) => ({ ...i, inGallery: inGallery.has(i.src) })); // 같으면 나란히 두 번 보인다. 다만 이렇게 빼서 글 수를 못 채우면 더 나빠진다. 못 채운 글은 건물 사진으로
// 떨어져 같은 사진이 여러 장 생기기 때문이다. 그래서 전부 채울 수 있을 때만 뺀다.
const notInGallery = base.filter((i) => !inGallery.has(i.src));
const pool = (notInGallery.length >= targets.length ? notInGallery : base).map((i) => ({ ...i, inGallery: inGallery.has(i.src) }));
if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); } if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); }
if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); } if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); }

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}장`);