o2o-infinith-demo/supporters/scripts/collect_images.mjs

103 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 홈페이지 이미지 수집 (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`);