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