feat(supporters): 홈페이지 이미지 단계 — 의료법 기준 이미지 규칙 문서, collect_images.mjs+img_process.py(법 조건부 이미지 자동 제외, 글자 이미지 검사), 워커 images 단계, 글별 히어로·갤러리 매칭. 원진 66장 반영 재배포

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-08 09:34:48 +09:00
parent eb873bc43a
commit dc0d233b58
9 changed files with 397 additions and 14 deletions

View File

@ -0,0 +1,40 @@
# 서포터즈 사이트 이미지 사용 규칙 v1.0
작성 2026-09-08 · 근거 조문은 원문 기준으로 정리했으나 법률 자문이 아니며, 계약 전 변호사 확인을 권한다. 병원(원진성형외과·뷰성형외과)은 파트너·고객이므로 홈페이지 자산의 사용 허락은 전제로 본다. 따라서 이 규칙이 다루는 것은 "써도 되는가"가 아니라 **"법이 조건을 붙이는 이미지가 무엇이고, 자동 수집 단계에서 그 조건을 확인할 수 있는가"** 다. 확인할 수 없으면 자동 단계에서 빼고, 병원이 서면으로 조건을 확인해 주면 사람이 넣는다.
## 1. 법적 근거와 이미지 규칙의 대응
| 근거 | 내용 | 이미지 규칙 |
|---|---|---|
| 의료법 제56조 제2항 제2호 | 환자의 치료경험담 광고 금지 | 환자 후기·사례(case·review·사례·후기) 이미지 제외. 전후 사진도 환자 사례이므로 같은 취급 |
| 의료법 제56조 제2항 제4호 | 다른 의료인·의료기관과의 비교 광고 금지 | 비교 표·그래프 이미지(vs·비교·타원) 제외 |
| 의료법 제56조 제2항 제6호 | 수술 장면 등 직접적인 시술 행위 노출 금지 | 수술 장면 사진(수술 중·절개·기구 삽입) 제외. 수술실 "공간" 사진은 시설로 허용 |
| 의료법 제56조 제2항 제7호 | 부작용 등 중요 정보 누락 금지 | 이미지와 무관하게 모든 글 하단에 부작용 고지(factSheet.sideEffectNotice) 유지 |
| 의료법 제56조 제2항 제8호 | 객관적 사실 과장·소비자 오인 금지 | 캡션은 사실(무엇을 찍은 사진인지, 출처, 캡처일)만. 효과·결과 서술 금지(발행 게이트가 본문과 캡션 텍스트 검사) |
| 의료법 제56조 제2항 제14호 | 인증·보증·추천 표현 금지(법령상 인증 제외) | 수상·인증 마크·상장 이미지(award·수상·인증·certif) 자동 제외. 법령상 인증(식약처 허가 등)은 병원 확인 후 사람이 넣는다 |
| 의료광고 심의 기준(대한의사협회 의료광고심의위원회) 전후 사진 조건 | 동일 조건 촬영, 보정 없음, 부작용 병기, 환자 동의 | 자동 수집으로는 네 조건을 확인할 수 없다 → **전후 사진(before·after·bna·전후·비포·애프터) 자동 제외**. 병원이 동의서·촬영 조건을 서면 확인하면 수동 반영 |
| 개인정보보호법(민감정보)·초상권 | 환자·모델 얼굴·신체 사진은 동의 필요 | 환자·모델(model·모델·환자) 사진 제외. 신체 부위(가슴·힙·바디) 실사 사진은 일러스트가 아니면 제외 |
| 표시·광고법·추천보증 심사지침 | 경제적 이해관계 표시 | 이미지 규칙이 아니라 사이트 고지(Disclosure·site.sponsorNotice)로 처리 |
## 2. 자동 수집 단계에서 허용하는 이미지 (파트너 자산)
| 유형 | 판정 근거(alt·파일명·페이지) | 용도 |
|---|---|---|
| 의료진 프로필 | 의료진 상세 페이지의 원장 사진(alt에 "원장") | authors.json physicians[].image, 글 검토자 스트립 |
| 시설·건물·공간 | 소개 페이지 커버, 시설·층·로비·상담실·수술실(공간)·입원실·검진센터 | site.heroImage·buildingImage·insideGallery·clinicGallery |
| 시술 설명 일러스트·단계 도해 | illu·step·단계·도해·diagram·방법·원리·system, 시술 페이지 | 글 히어로·갤러리 (캡션: "출처: 홈페이지 {페이지 제목}") |
| 장비 | 장비·equipment·laser·레이저·machine (제품명은 페이지 텍스트에 있는 이름만) | 글 갤러리 |
| 로고 | 병원 제공 로고 파일 | 헤더·푸터·파비콘·카드 대체 이미지 |
기술 조건: 폭 600px 이상(의료진은 300px), 가로세로비 0.4~2.6, GIF·아이콘·버튼·배경·배너·팝업·SNS 아이콘 제외, 최대 1600px 로 리사이즈, JPEG 저장, 원본 URL·페이지·캡처일을 images.json 에 기록.
## 3. 사람이 확인해야 넣는 이미지
- 전후 사진: 병원이 환자 동의서·동일 조건 촬영·무보정을 확인하고, 글에 부작용·개인차 문구가 붙은 경우에만. 자동 빌드 미리보기에는 넣지 않는다.
- 법령상 인증(식약처 허가·ISO 등): 원문 링크(§3-4 regulation 소스)와 함께.
- 환자·모델 얼굴이 있는 사진: 동의 확인 후.
## 4. 게이트와의 연결
- 모든 이미지는 alt 필수(check.mjs IMG_ALT). 캡션·alt 텍스트는 본문과 같은 금칙어·비교·결론 검사를 받는다.
- 이미지 파일명·alt 에 전후·후기·비교·수상 어휘가 있으면 빌드 경고(추가 예정 IMAGE_RULE).

View File

@ -302,6 +302,14 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
- 원진 결과: 채널 736편 · 설명형 롱폼 10 · 쇼츠 10 · 글 2편에 영상 3편씩(눈성형, 상담 질문), 나머지 4편은 로고 카드 · 헤더 로고·파비콘 = 원진 로고(`supporters/briefs/wonjin/logo-horizontal.svg`). https://supporters-wonjin.vercel.app 재배포. 뷰성형외과 회귀: 게이트 16/16, 빌드 오류 0.
- 남은 것: 홈페이지 이미지(전후 제외) 히어로·갤러리, 뉴스룸, 감사 재실행. 쇼츠 정리 답은 사람 입력.
### 11-11. 홈페이지 이미지 단계 (법 기준) · 2026-09-08
- haewon 지시: 전후 사진을 거르는 규칙은 의료법 등 관련 법규를 기준으로, 병원은 파트너·고객이므로 법에 걸리지 않는 범위에서 진행.
- 규칙 문서 `docs/INFINITH_Supporters_Image_Rules.md`: 의료법 56조 2항(2호 치료경험담, 4호 비교, 6호 수술 장면 노출, 7호 부작용 누락, 8호 과장, 14호 인증·보증), 의료광고 심의 기준의 전후 사진 조건(동일 조건·무보정·부작용 병기·환자 동의), 개인정보보호법·초상권을 이미지 유형별 규칙으로 대응. 자동 수집으로 조건을 확인할 수 없는 것(전후·사례·후기·모델·환자·비교·수상·인증·수술 장면·신체 실사)은 제외, 병원 서면 확인 후 사람이 넣는다. 법률 자문 아님, 변호사 확인 권고.
- `supporters/scripts/collect_images.mjs` + `img_process.py`(PIL): 근거 페이지 이미지 → 규칙 분류(의료진·시설·시술 도해·장비) → 다운로드 → 폭·비율·애니메이션·**글자 이미지 검사(흰 배경 평탄화 후 흰 픽셀 80% 이상 제외)** → 1600px JPEG → `public/img/{doctors,clinic,procedure,equipment}` + `src/data/images.json`(alt·캡션 "출처: 홈페이지 {페이지} ({캡처일})"·원본 URL). 신체 부위(가슴·힙·바디·지방) 페이지 실사는 파일명에 illu·step·diagram·circle 이 있을 때만.
- 워커 `images` 단계(youtube 다음): site.heroImage(가로 시설 사진)·buildingImage·insideGallery·clinicGallery·imageCredit, physicians[].image. `default_briefs.mjs` 가 글별 히어로 1 + 갤러리 최대 3(근거 페이지에서 나온 도해·장비, 병원 소개는 시설 사진). generate 단계의 기존 글 갱신이 hero·gallery·thumbnail 도 brief 기준으로 덮어쓴다.
- 원진 결과: 후보 427 → 저장 66장(의료진 17, 시설 9, 시술 40). 6편 전부 히어로 있음, 눈성형·상담 질문 글 영상 3편, 원장 17명 사진. 눈으로 확인한 것: 상담 장면·수술실 모니터링·코 수술 도해는 적합, 흰 배경 마케팅 문구 이미지(힙업 카피)는 1차에서 통과했다가 평탄화 순서 수정 후 제외. https://supporters-wonjin.vercel.app 재배포.
- 한계: 이미지 내용을 사람이 전수 확인하지는 않았다(파일명·alt·흰 배경 휴리스틱). 얼굴·신체 검출은 없으므로 병원 송부 전 사람이 갤러리를 한 번 훑는다. 뉴스룸·감사 재실행은 아직.
---
## 12. haewon 결정 반영과 제안 (2026-09-07 세션 2)

View File

@ -0,0 +1,102 @@
// 홈페이지 이미지 수집 (v1 §3 Phase 2 "이미지 URL 목록 필터", §4 "이미지 필터·리사이즈"). 규칙은 docs/INFINITH_Supporters_Image_Rules.md.
// node scripts/collect_images.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--max-procedure 40] [--max-facility 12] [--max-equipment 10]
// 산출: <site>/public/img/{doctors,clinic,procedure,equipment}/*.jpg + <site>/src/data/images.json (매니페스트: 유형·alt·캡션·원본 URL·페이지·캡처일)
// 자동 제외(법 조건을 자동으로 확인할 수 없는 것): 전후·환자 사례·후기·모델·비교·수상·인증·수술 장면·신체 실사(가슴·힙·바디). 아이콘·버튼·배너·팝업·SNS 는 기술 제외.
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
import { join, resolve, basename, extname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const here = fileURLToPath(new URL('.', import.meta.url));
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 clinic = opt('clinic'); const EV = resolve(opt('evidence', `../evidence/${clinic}`)); const SITE = resolve(opt('site', '.'));
const LIMIT = { doctors: 60, clinic: Number(opt('max-facility', '12')), procedure: Number(opt('max-procedure', '40')), equipment: Number(opt('max-equipment', '10')) };
if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic 과 --evidence 필요'); process.exit(2); }
const today = new Date().toISOString().slice(0, 10);
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)';
// ---------- 규칙 ----------
// 법 조건부(자동 제외): 전후·사례·후기·모델·환자·비교·수상·인증·수술 장면
const LEGAL_EXCLUDE = /before|after|bna|b&a|전후|비포|애프터|case|사례|review|후기|testimon|model|모델|환자|patient|\bvs\b|비교|compare|award|수상|certif|인증|상장|surgery_scene|수술장면|수술 장면|operation_|op_/i;
// 신체 실사(일러스트가 아니면 제외)
const BODY = /가슴|breast|힙|hip|엉덩이|body|바디|지방흡입|lipo|nude|누드|허벅지|복부|abdomen/i;
const ILLU = /illu|illust|step|단계|도해|diagram|scheme|방법|원리|system|시스템|process|프로세스|graphic|infographic|circle|feature/i;
// 기술 제외
const TECH_EXCLUDE = /logo|로고|icon|ico_|btn|button|arrow|bg_|_bg|background|banner|배너|popup|팝업|floating|sns|kakao|naver|instagram|youtube|facebook|blank|dot\.|line_|quote|bullet|check\.|close|menu|nav|footer|header|sprite|loading|spinner|map\.|qr|price|가격|할인|event|이벤트|coupon|thumb_|thumbs_|_active|_off\.|_on\./i;
const NOISE_PAGE = /약관|개인정보|policy|private|terms|event|이벤트|자가진단|trtest|학술|채용|recruit|board|notice|news|blog|login|join|cart|sitemap|before-after|bna/i;
const idx = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
const doctors = existsSync(join(EV, 'doctors.json')) ? JSON.parse(readFileSync(join(EV, 'doctors.json'), 'utf8')).doctors ?? [] : [];
const doctorByUrl = new Map(doctors.map((d) => [d.url.replace(/\/$/, '').toLowerCase(), d.id]));
function classify(img, page) {
const s = `${img.src} ${img.alt ?? ''}`;
const alt = img.alt ?? '';
if (TECH_EXCLUDE.test(s)) return null;
if (LEGAL_EXCLUDE.test(s)) return null;
if (page.type === 'doctor-detail' || (page.type === 'doctor' && /원장|의사|전문의|doctor|dr_/i.test(s))) {
if (/원장|doctor|dr_|profile|의사|전문의/i.test(s)) return 'doctors';
}
if (/시설|interior|병원|건물|외관|lobby|로비|room|층|수술실|상담실|입원|검진센터|hospital|clinic|building|floor|facility|cover|공간|센터|대기/i.test(s) || page.type === 'facilities' || page.type === 'about') return 'clinic';
if (/장비|equip|laser|레이저|machine|기기|device/i.test(s)) return 'equipment';
if (page.type === 'procedure' || /procedure|contents|시술|수술/i.test(page.url)) {
// 신체 부위(가슴·힙·바디·지방) 페이지의 실사는 제외. 파일명에 일러스트·단계·도해 표시가 있을 때만 허용(alt 의 "방법" 같은 단어는 근거로 보지 않는다)
if ((BODY.test(s) || BODY.test(page.url)) && !/illu|illust|step|diagram|scheme|infographic|circle|icon_/i.test(img.src)) return null;
if (ILLU.test(s) || alt.length >= 8) return 'procedure';
}
return null;
}
// ---------- 후보 수집 ----------
const cands = []; const seen = new Set();
for (const p of idx.pages) {
if (p.status !== 200 || NOISE_PAGE.test(p.url + ' ' + (p.title ?? ''))) continue;
const pg = JSON.parse(readFileSync(join(EV, p.file), 'utf8'));
for (const img of pg.images ?? []) {
if (!img.src || !/\.(jpe?g|png|webp)(\?|$)/i.test(img.src)) continue;
const key = img.src.replace(/^https?:\/\/(www\.)?/, '').toLowerCase(); if (seen.has(key)) continue; seen.add(key);
const cat = classify(img, p); if (!cat) continue;
cands.push({ cat, src: img.src, alt: (img.alt ?? '').trim(), page: { url: p.url, title: (p.title ?? '').replace(/\s*[|-]\s*[^|-]*$/, '').trim() || p.type, type: p.type }, doctorId: doctorByUrl.get(p.url.replace(/\/$/, '').toLowerCase()) ?? null });
}
}
// 우선순위: 의료진(대표 사진 우선) → 시설(커버·건물 우선) → 시술(alt 긴 것) → 장비
const rank = (c) => (c.cat === 'doctors' ? (/img_dr_|profile/.test(c.src) ? 0 : 1) : c.cat === 'clinic' ? (/cover|외관|건물|building/.test(c.src + c.alt) ? 0 : 1) : c.cat === 'procedure' ? (ILLU.test(c.src + c.alt) ? 0 : 1) : 0);
cands.sort((a, b) => rank(a) - rank(b) || b.alt.length - a.alt.length);
console.log(`후보 ${cands.length} (의료진 ${cands.filter((c) => c.cat === 'doctors').length}, 시설 ${cands.filter((c) => c.cat === 'clinic').length}, 시술 ${cands.filter((c) => c.cat === 'procedure').length}, 장비 ${cands.filter((c) => c.cat === 'equipment').length})`);
// ---------- 다운로드·검사·리사이즈 ----------
const OUT = join(SITE, 'public', 'img'); const TMP = join(SITE, '.img-tmp'); mkdirSync(TMP, { recursive: true });
const count = { doctors: 0, clinic: 0, procedure: 0, equipment: 0 }; const items = []; const doctorDone = new Set();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (const c of cands) {
if (count[c.cat] >= LIMIT[c.cat]) continue;
if (c.cat === 'doctors' && c.doctorId && doctorDone.has(c.doctorId)) continue; // 원장당 1장
try {
const r = await fetch(c.src, { headers: { 'user-agent': UA, referer: c.page.url }, signal: AbortSignal.timeout(20000) });
if (!r.ok || !/image/.test(r.headers.get('content-type') ?? '')) continue;
const buf = Buffer.from(await r.arrayBuffer());
const hash = createHash('md5').update(buf).digest('hex').slice(0, 10);
const tmp = join(TMP, `${hash}${extname(new URL(c.src).pathname) || '.img'}`); writeFileSync(tmp, buf);
mkdirSync(join(OUT, c.cat), { recursive: true });
const name = `${basename(new URL(c.src).pathname).replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9가-힣_-]+/g, '-').slice(0, 40)}-${hash}.jpg`;
const dst = join(OUT, c.cat, name);
const pr = spawnSync('python3', [join(here, 'img_process.py'), tmp, dst, '1600', c.cat === 'doctors' ? '300' : '600'], { encoding: 'utf8' });
const m = (pr.stdout ?? '').trim().match(/^ok (\d+) (\d+)/);
unlinkSync(tmp);
if (!m) continue;
count[c.cat]++; if (c.doctorId) doctorDone.add(c.doctorId);
items.push({ id: name.replace(/\.jpg$/, ''), category: c.cat, src: `/img/${c.cat}/${name}`, alt: c.alt || altFallback(c), caption: `출처: 홈페이지 ${c.page.title} (${today} 캡처)`, sourceUrl: c.src, page: c.page, width: Number(m[1]), height: Number(m[2]), doctorId: c.doctorId, capturedAt: today });
process.stdout.write(`\r 저장 ${items.length}`);
await sleep(150);
} catch { /* 건너뜀 */ }
}
console.log();
try { for (const f of readdirSync(TMP)) unlinkSync(join(TMP, f)); } catch { /* */ }
function altFallback(c) { return c.cat === 'doctors' ? `${doctors.find((d) => d.id === c.doctorId)?.name ?? '원장'} 원장` : c.cat === 'clinic' ? `${c.page.title} 시설 사진` : `${c.page.title} 설명 이미지`; }
const manifest = { clinic, capturedAt: today, rules: 'docs/INFINITH_Supporters_Image_Rules.md', counts: count, items };
mkdirSync(join(SITE, 'src', 'data'), { recursive: true });
writeFileSync(join(SITE, 'src', 'data', 'images.json'), JSON.stringify(manifest, null, 1));
console.log(`저장 ${items.length}장 · ${JSON.stringify(count)} → src/data/images.json`);

View File

@ -116,11 +116,25 @@ function matchVideos(c) {
if (!kws.length) return [];
return longs.filter((v) => kws.some((k) => v.title.includes(k))).slice(0, 3).map((v) => ({ id: v.id, title: v.title, published: v.published }));
}
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => ({
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: matchVideos({ ...c, area }), gallery: [],
}));
// 글 ↔ 이미지: images.json(collect_images.mjs)의 시술 도해·장비 중 근거 페이지에서 나온 것을 히어로 1 + 갤러리 최대 3. 병원 소개 글은 시설 사진.
const imagesPath = join(SITE, 'src/data/images.json');
const imgs = existsSync(imagesPath) ? JSON.parse(readFileSync(imagesPath, 'utf8')).items ?? [] : [];
const norm = (u) => String(u).replace(/\/$/, '').toLowerCase();
function matchImages(c, pg) {
const pages = new Set(pg.map(norm));
let pool = c.id === 'what-is-clinic' ? imgs.filter((i) => i.category === 'clinic') : imgs.filter((i) => ['procedure', 'equipment'].includes(i.category) && pages.has(norm(i.page.url)));
if (c.id === 'visit-guide' || c.id === 'consult-questions') pool = imgs.filter((i) => i.category === 'clinic').slice(0, 2);
const pick = pool.slice(0, 4).map((i) => ({ src: i.src, alt: i.alt, caption: i.caption }));
return { hero: pick[0], gallery: pick.slice(1, 4) };
}
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => {
const { hero, gallery } = matchImages(c, pg);
return {
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: matchVideos({ ...c, area }), ...(hero ? { hero, thumbnail: hero.src } : {}), gallery,
};
});
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""이미지 검사·리사이즈 (collect_images.mjs 가 호출). 입력 파일을 열어 크기를 확인하고 최대 폭으로 줄여 JPEG 로 저장한다.
사용: img_process.py <in> <out.jpg> <max_width> <min_width>
출력: "ok <w> <h>" 또는 "skip <이유>". 애니메이션 GIF·너무 작은 이미지·극단적 비율은 skip."""
import sys
from PIL import Image, ImageOps
src, dst, max_w, min_w = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
try:
im = Image.open(src)
if getattr(im, "is_animated", False) and im.n_frames > 1:
print("skip animated"); sys.exit(0)
im = ImageOps.exif_transpose(im)
w, h = im.size
if w < min_w:
print(f"skip small {w}x{h}"); sys.exit(0)
ratio = w / h if h else 0
if ratio < 0.4 or ratio > 2.6:
print(f"skip ratio {w}x{h}"); sys.exit(0)
if im.mode in ("RGBA", "LA", "P"):
bg = Image.new("RGB", im.size, (255, 255, 255))
bg.paste(im.convert("RGBA"), mask=im.convert("RGBA").split()[-1])
im = bg
else:
im = im.convert("RGB")
# 글자만 있는 마케팅 블록(흰 배경 위 문구)·장식 오브젝트 제외: 흰 배경으로 평탄화한 뒤 축소본에서 거의 흰 픽셀 비율이 80% 이상이면 건너뛴다
small = im.resize((64, max(1, round(64 * h / w))))
px = list(small.getdata())
white = sum(1 for r, g, b in px if r > 235 and g > 235 and b > 235) / max(1, len(px))
if white >= 0.80:
print(f"skip textblock white={white:.2f}"); sys.exit(0)
if w > max_w:
im = im.resize((max_w, round(h * max_w / w)), Image.LANCZOS)
im.save(dst, "JPEG", quality=85, optimize=True)
print(f"ok {im.size[0]} {im.size[1]}")
except Exception as e: # noqa: BLE001
print(f"skip error {e}")

View File

@ -0,0 +1,102 @@
// 홈페이지 이미지 수집 (v1 §3 Phase 2 "이미지 URL 목록 필터", §4 "이미지 필터·리사이즈"). 규칙은 docs/INFINITH_Supporters_Image_Rules.md.
// node scripts/collect_images.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--max-procedure 40] [--max-facility 12] [--max-equipment 10]
// 산출: <site>/public/img/{doctors,clinic,procedure,equipment}/*.jpg + <site>/src/data/images.json (매니페스트: 유형·alt·캡션·원본 URL·페이지·캡처일)
// 자동 제외(법 조건을 자동으로 확인할 수 없는 것): 전후·환자 사례·후기·모델·비교·수상·인증·수술 장면·신체 실사(가슴·힙·바디). 아이콘·버튼·배너·팝업·SNS 는 기술 제외.
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
import { join, resolve, basename, extname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const here = fileURLToPath(new URL('.', import.meta.url));
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 clinic = opt('clinic'); const EV = resolve(opt('evidence', `../evidence/${clinic}`)); const SITE = resolve(opt('site', '.'));
const LIMIT = { doctors: 60, clinic: Number(opt('max-facility', '12')), procedure: Number(opt('max-procedure', '40')), equipment: Number(opt('max-equipment', '10')) };
if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic 과 --evidence 필요'); process.exit(2); }
const today = new Date().toISOString().slice(0, 10);
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)';
// ---------- 규칙 ----------
// 법 조건부(자동 제외): 전후·사례·후기·모델·환자·비교·수상·인증·수술 장면
const LEGAL_EXCLUDE = /before|after|bna|b&a|전후|비포|애프터|case|사례|review|후기|testimon|model|모델|환자|patient|\bvs\b|비교|compare|award|수상|certif|인증|상장|surgery_scene|수술장면|수술 장면|operation_|op_/i;
// 신체 실사(일러스트가 아니면 제외)
const BODY = /가슴|breast|힙|hip|엉덩이|body|바디|지방흡입|lipo|nude|누드|허벅지|복부|abdomen/i;
const ILLU = /illu|illust|step|단계|도해|diagram|scheme|방법|원리|system|시스템|process|프로세스|graphic|infographic|circle|feature/i;
// 기술 제외
const TECH_EXCLUDE = /logo|로고|icon|ico_|btn|button|arrow|bg_|_bg|background|banner|배너|popup|팝업|floating|sns|kakao|naver|instagram|youtube|facebook|blank|dot\.|line_|quote|bullet|check\.|close|menu|nav|footer|header|sprite|loading|spinner|map\.|qr|price|가격|할인|event|이벤트|coupon|thumb_|thumbs_|_active|_off\.|_on\./i;
const NOISE_PAGE = /약관|개인정보|policy|private|terms|event|이벤트|자가진단|trtest|학술|채용|recruit|board|notice|news|blog|login|join|cart|sitemap|before-after|bna/i;
const idx = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
const doctors = existsSync(join(EV, 'doctors.json')) ? JSON.parse(readFileSync(join(EV, 'doctors.json'), 'utf8')).doctors ?? [] : [];
const doctorByUrl = new Map(doctors.map((d) => [d.url.replace(/\/$/, '').toLowerCase(), d.id]));
function classify(img, page) {
const s = `${img.src} ${img.alt ?? ''}`;
const alt = img.alt ?? '';
if (TECH_EXCLUDE.test(s)) return null;
if (LEGAL_EXCLUDE.test(s)) return null;
if (page.type === 'doctor-detail' || (page.type === 'doctor' && /원장|의사|전문의|doctor|dr_/i.test(s))) {
if (/원장|doctor|dr_|profile|의사|전문의/i.test(s)) return 'doctors';
}
if (/시설|interior|병원|건물|외관|lobby|로비|room|층|수술실|상담실|입원|검진센터|hospital|clinic|building|floor|facility|cover|공간|센터|대기/i.test(s) || page.type === 'facilities' || page.type === 'about') return 'clinic';
if (/장비|equip|laser|레이저|machine|기기|device/i.test(s)) return 'equipment';
if (page.type === 'procedure' || /procedure|contents|시술|수술/i.test(page.url)) {
// 신체 부위(가슴·힙·바디·지방) 페이지의 실사는 제외. 파일명에 일러스트·단계·도해 표시가 있을 때만 허용(alt 의 "방법" 같은 단어는 근거로 보지 않는다)
if ((BODY.test(s) || BODY.test(page.url)) && !/illu|illust|step|diagram|scheme|infographic|circle|icon_/i.test(img.src)) return null;
if (ILLU.test(s) || alt.length >= 8) return 'procedure';
}
return null;
}
// ---------- 후보 수집 ----------
const cands = []; const seen = new Set();
for (const p of idx.pages) {
if (p.status !== 200 || NOISE_PAGE.test(p.url + ' ' + (p.title ?? ''))) continue;
const pg = JSON.parse(readFileSync(join(EV, p.file), 'utf8'));
for (const img of pg.images ?? []) {
if (!img.src || !/\.(jpe?g|png|webp)(\?|$)/i.test(img.src)) continue;
const key = img.src.replace(/^https?:\/\/(www\.)?/, '').toLowerCase(); if (seen.has(key)) continue; seen.add(key);
const cat = classify(img, p); if (!cat) continue;
cands.push({ cat, src: img.src, alt: (img.alt ?? '').trim(), page: { url: p.url, title: (p.title ?? '').replace(/\s*[|-]\s*[^|-]*$/, '').trim() || p.type, type: p.type }, doctorId: doctorByUrl.get(p.url.replace(/\/$/, '').toLowerCase()) ?? null });
}
}
// 우선순위: 의료진(대표 사진 우선) → 시설(커버·건물 우선) → 시술(alt 긴 것) → 장비
const rank = (c) => (c.cat === 'doctors' ? (/img_dr_|profile/.test(c.src) ? 0 : 1) : c.cat === 'clinic' ? (/cover|외관|건물|building/.test(c.src + c.alt) ? 0 : 1) : c.cat === 'procedure' ? (ILLU.test(c.src + c.alt) ? 0 : 1) : 0);
cands.sort((a, b) => rank(a) - rank(b) || b.alt.length - a.alt.length);
console.log(`후보 ${cands.length} (의료진 ${cands.filter((c) => c.cat === 'doctors').length}, 시설 ${cands.filter((c) => c.cat === 'clinic').length}, 시술 ${cands.filter((c) => c.cat === 'procedure').length}, 장비 ${cands.filter((c) => c.cat === 'equipment').length})`);
// ---------- 다운로드·검사·리사이즈 ----------
const OUT = join(SITE, 'public', 'img'); const TMP = join(SITE, '.img-tmp'); mkdirSync(TMP, { recursive: true });
const count = { doctors: 0, clinic: 0, procedure: 0, equipment: 0 }; const items = []; const doctorDone = new Set();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (const c of cands) {
if (count[c.cat] >= LIMIT[c.cat]) continue;
if (c.cat === 'doctors' && c.doctorId && doctorDone.has(c.doctorId)) continue; // 원장당 1장
try {
const r = await fetch(c.src, { headers: { 'user-agent': UA, referer: c.page.url }, signal: AbortSignal.timeout(20000) });
if (!r.ok || !/image/.test(r.headers.get('content-type') ?? '')) continue;
const buf = Buffer.from(await r.arrayBuffer());
const hash = createHash('md5').update(buf).digest('hex').slice(0, 10);
const tmp = join(TMP, `${hash}${extname(new URL(c.src).pathname) || '.img'}`); writeFileSync(tmp, buf);
mkdirSync(join(OUT, c.cat), { recursive: true });
const name = `${basename(new URL(c.src).pathname).replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9가-힣_-]+/g, '-').slice(0, 40)}-${hash}.jpg`;
const dst = join(OUT, c.cat, name);
const pr = spawnSync('python3', [join(here, 'img_process.py'), tmp, dst, '1600', c.cat === 'doctors' ? '300' : '600'], { encoding: 'utf8' });
const m = (pr.stdout ?? '').trim().match(/^ok (\d+) (\d+)/);
unlinkSync(tmp);
if (!m) continue;
count[c.cat]++; if (c.doctorId) doctorDone.add(c.doctorId);
items.push({ id: name.replace(/\.jpg$/, ''), category: c.cat, src: `/img/${c.cat}/${name}`, alt: c.alt || altFallback(c), caption: `출처: 홈페이지 ${c.page.title} (${today} 캡처)`, sourceUrl: c.src, page: c.page, width: Number(m[1]), height: Number(m[2]), doctorId: c.doctorId, capturedAt: today });
process.stdout.write(`\r 저장 ${items.length}`);
await sleep(150);
} catch { /* 건너뜀 */ }
}
console.log();
try { for (const f of readdirSync(TMP)) unlinkSync(join(TMP, f)); } catch { /* */ }
function altFallback(c) { return c.cat === 'doctors' ? `${doctors.find((d) => d.id === c.doctorId)?.name ?? '원장'} 원장` : c.cat === 'clinic' ? `${c.page.title} 시설 사진` : `${c.page.title} 설명 이미지`; }
const manifest = { clinic, capturedAt: today, rules: 'docs/INFINITH_Supporters_Image_Rules.md', counts: count, items };
mkdirSync(join(SITE, 'src', 'data'), { recursive: true });
writeFileSync(join(SITE, 'src', 'data', 'images.json'), JSON.stringify(manifest, null, 1));
console.log(`저장 ${items.length}장 · ${JSON.stringify(count)} → src/data/images.json`);

View File

@ -116,11 +116,25 @@ function matchVideos(c) {
if (!kws.length) return [];
return longs.filter((v) => kws.some((k) => v.title.includes(k))).slice(0, 3).map((v) => ({ id: v.id, title: v.title, published: v.published }));
}
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => ({
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: matchVideos({ ...c, area }), gallery: [],
}));
// 글 ↔ 이미지: images.json(collect_images.mjs)의 시술 도해·장비 중 근거 페이지에서 나온 것을 히어로 1 + 갤러리 최대 3. 병원 소개 글은 시설 사진.
const imagesPath = join(SITE, 'src/data/images.json');
const imgs = existsSync(imagesPath) ? JSON.parse(readFileSync(imagesPath, 'utf8')).items ?? [] : [];
const norm = (u) => String(u).replace(/\/$/, '').toLowerCase();
function matchImages(c, pg) {
const pages = new Set(pg.map(norm));
let pool = c.id === 'what-is-clinic' ? imgs.filter((i) => i.category === 'clinic') : imgs.filter((i) => ['procedure', 'equipment'].includes(i.category) && pages.has(norm(i.page.url)));
if (c.id === 'visit-guide' || c.id === 'consult-questions') pool = imgs.filter((i) => i.category === 'clinic').slice(0, 2);
const pick = pool.slice(0, 4).map((i) => ({ src: i.src, alt: i.alt, caption: i.caption }));
return { hero: pick[0], gallery: pick.slice(1, 4) };
}
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => {
const { hero, gallery } = matchImages(c, pg);
return {
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
videos: matchVideos({ ...c, area }), ...(hero ? { hero, thumbnail: hero.src } : {}), gallery,
};
});
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""이미지 검사·리사이즈 (collect_images.mjs 가 호출). 입력 파일을 열어 크기를 확인하고 최대 폭으로 줄여 JPEG 로 저장한다.
사용: img_process.py <in> <out.jpg> <max_width> <min_width>
출력: "ok <w> <h>" 또는 "skip <이유>". 애니메이션 GIF·너무 작은 이미지·극단적 비율은 skip."""
import sys
from PIL import Image, ImageOps
src, dst, max_w, min_w = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
try:
im = Image.open(src)
if getattr(im, "is_animated", False) and im.n_frames > 1:
print("skip animated"); sys.exit(0)
im = ImageOps.exif_transpose(im)
w, h = im.size
if w < min_w:
print(f"skip small {w}x{h}"); sys.exit(0)
ratio = w / h if h else 0
if ratio < 0.4 or ratio > 2.6:
print(f"skip ratio {w}x{h}"); sys.exit(0)
if im.mode in ("RGBA", "LA", "P"):
bg = Image.new("RGB", im.size, (255, 255, 255))
bg.paste(im.convert("RGBA"), mask=im.convert("RGBA").split()[-1])
im = bg
else:
im = im.convert("RGB")
# 글자만 있는 마케팅 블록(흰 배경 위 문구)·장식 오브젝트 제외: 흰 배경으로 평탄화한 뒤 축소본에서 거의 흰 픽셀 비율이 80% 이상이면 건너뛴다
small = im.resize((64, max(1, round(64 * h / w))))
px = list(small.getdata())
white = sum(1 for r, g, b in px if r > 235 and g > 235 and b > 235) / max(1, len(px))
if white >= 0.80:
print(f"skip textblock white={white:.2f}"); sys.exit(0)
if w > max_w:
im = im.resize((max_w, round(h * max_w / w)), Image.LANCZOS)
im.save(dst, "JPEG", quality=85, optimize=True)
print(f"ok {im.size[0]} {im.size[1]}")
except Exception as e: # noqa: BLE001
print(f"skip error {e}")

View File

@ -26,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url');
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
const today = new Date().toISOString().slice(0, 10);
const PHASES = ['evidence', 'data', 'youtube', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
const PHASES = ['evidence', 'data', 'youtube', 'images', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
const startFrom = opt('from', 'evidence');
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })();
@ -175,6 +175,31 @@ await phase('youtube', () => {
return notes.concat(`${v.channel.title} · 영상 ${v.channel.videos} · 설명형 롱폼 ${v.topLong.length} · 쇼츠 ${v.topShorts.length}`).join(' · ');
});
// 2c. 홈페이지 이미지 (규칙: docs/INFINITH_Supporters_Image_Rules.md). 전후·사례·모델·비교·수상·수술 장면은 자동 제외.
await phase('images', () => {
if (flag('no-images')) return '건너뜀 (--no-images)';
const r = run('node', ['scripts/collect_images.mjs', '--clinic', clinic, '--evidence', EV, '--site', SITE]);
if (!r.ok || !existsSync(join(DATA, 'images.json'))) throw new Error(`이미지 수집 실패\n${tail(r.out)}`);
const m = readJson(join(DATA, 'images.json'));
const by = (c) => m.items.filter((i) => i.category === c);
const site = readJson(join(DATA, 'site.json'));
const clinicImgs = by('clinic');
const wide = clinicImgs.filter((i) => i.width / i.height >= 1.4);
if (wide[0] && !site.heroImage?.src) site.heroImage = { src: wide[0].src, alt: wide[0].alt, width: wide[0].width, height: wide[0].height };
const building = clinicImgs.find((i) => /외관|건물|building|exterior/.test(i.src + i.alt)) ?? clinicImgs[1] ?? clinicImgs[0];
if (building && !site.buildingImage?.src) site.buildingImage = { src: building.src, alt: building.alt, caption: building.caption };
const gal = (list) => list.map((i) => ({ src: i.src, alt: i.alt, caption: i.caption }));
if (!site.insideGallery?.length) site.insideGallery = gal(clinicImgs.slice(0, 6));
if (!site.clinicGallery?.length) site.clinicGallery = gal(clinicImgs.slice(6, 12).length ? clinicImgs.slice(6, 12) : clinicImgs.slice(0, 6));
site.imageCredit = site.imageCredit || `사진 출처: ${readJson(join(DATA, 'factSheet.json')).shortName || '병원'} 공식 홈페이지 (${m.capturedAt} 캡처)`;
writeJson(join(DATA, 'site.json'), site);
const authors = readJson(join(DATA, 'authors.json'));
let docImgs = 0;
for (const i of by('doctors')) if (i.doctorId && authors.physicians?.[i.doctorId] && !authors.physicians[i.doctorId].image) { authors.physicians[i.doctorId].image = i.src; docImgs++; }
writeJson(join(DATA, 'authors.json'), authors);
return `${m.items.length}장 (의료진 ${by('doctors').length}, 시설 ${clinicImgs.length}, 시술 ${by('procedure').length}, 장비 ${by('equipment').length}) · 원장 사진 ${docImgs}명 · 히어로 ${site.heroImage?.src ? '설정' : '없음'}`;
});
// 3. 병원 입력 반영 (있으면)
await phase('inputs', () => {
if (!opt('inputs') && !flag('from-supabase')) return '입력 없음 (확인 대기로 발행)';
@ -209,9 +234,13 @@ await phase('generate', () => {
const src = readFileSync(p, 'utf8');
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) continue;
const data = yaml.load(m[1]) ?? {};
const next = b.videos ?? [];
if (JSON.stringify(data.videos ?? []) === JSON.stringify(next)) continue; // brief 가 영상의 단일 원본. 같으면 건너뛴다
data.videos = next;
// brief 가 영상·히어로·갤러리·썸네일의 단일 원본. 같으면 건너뛴다
const next = { videos: b.videos ?? [], hero: b.hero ?? undefined, gallery: b.gallery ?? [], thumbnail: b.thumbnail ?? undefined };
const cur = { videos: data.videos ?? [], hero: data.hero ?? undefined, gallery: data.gallery ?? [], thumbnail: data.thumbnail ?? undefined };
if (JSON.stringify(cur) === JSON.stringify(next)) continue;
data.videos = next.videos; data.gallery = next.gallery;
if (next.hero) data.hero = next.hero; else delete data.hero;
if (next.thumbnail) data.thumbnail = next.thumbnail; else delete data.thumbnail;
writeFileSync(p, `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${m[2].trim()}\n`);
attached++;
}