// 홈페이지 이미지 수집 (v1 §3 Phase 2 "이미지 URL 목록 필터", §4 "이미지 필터·리사이즈"). 규칙은 docs/INFINITH_Supporters_Image_Rules.md. // node scripts/collect_images.mjs --clinic --evidence > --site [--max-procedure 40] [--max-facility 12] [--max-equipment 10] // 산출: /public/img/{doctors,clinic,procedure,equipment}/*.jpg + /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`);