// 글마다 서로 다른 대표 이미지를 배정한다. // node scripts/assign_post_heroes.mjs --site [--dry] // // 왜 필요한가: PostCard 의 썸네일 순서는 "thumbnail → hero.src → 첫 영상 → 건물 사진 → 로고" 다. // 앞의 셋이 없으면 모든 글이 건물 사진 한 장으로 떨어져 목록에 같은 사진이 나란히 선다. // 글마다 thumbnail 이 있어도 값이 같으면 결과는 똑같다. 그래서 "비어 있는 글"과 "겹치는 글"을 함께 다시 배정한다. // // 규칙(docs/INFINITH_Supporters_Image_Rules.md §2) // - 글 히어로의 지정 용도는 시술 설명 일러스트(procedure)와 장비(equipment)다. 시설(clinic)은 그 다음으로 쓴다. // - 의료진 사진(doctors)은 쓰지 않는다. 저자·검토자 표시가 지정 용도이고, 글 대표 이미지로 쓰면 그 원장이 쓴 글로 읽힌다. // - site.heroImage·buildingImage 로 이미 쓰는 사진은 뺀다. 홈에서 보이는 사진이 목록에 또 서면 중복으로 읽힌다. // - alt 는 수집 원문을 그대로 쓰지 않는다. 원문 alt 는 술기 설명 문단이라 고객 화면에 낼 수 없다. 첫 구절만 줄여 쓴다. // - 이미지가 글보다 적으면 남는 글은 비워 둔다. 같은 사진을 다시 쓰면 처음 문제로 돌아간다. import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; 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 flag = (k) => args.includes(`--${k}`); const SITE = resolve(opt('site', '.')); const DRY = flag('dry'); const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); const DATA = join(SITE, 'src', 'data'); const POSTS = join(SITE, 'src', 'content', 'posts'); if (!existsSync(POSTS)) { console.log('글 폴더 없음 · 건너뜀'); process.exit(0); } const manifestPath = join(DATA, 'images.json'); if (!existsSync(manifestPath)) { console.log('images.json 없음 · 건너뜀 (수집된 이미지가 없다)'); process.exit(0); } const manifest = readJson(manifestPath); 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 clinicName = fact.shortName || fact.name || ''; // ---------- 글 읽기 ---------- const HERO_BLOCK = /^hero:\n(?:[ \t]+\S.*\n)+/m; // 블록 표기 const HERO_FLOW = /^hero:[ \t]*\{.*\}[ \t]*\n/m; // 한 줄 표기 const THUMB_LINE = /^thumbnail:[ \t]*.*\n/m; const posts = readdirSync(POSTS).filter((f) => f.endsWith('.md')).sort().map((file) => { const path = join(POSTS, file); const text = readFileSync(path, 'utf8'); const fm = (text.match(/^---\n([\s\S]*?)\n---/) ?? [, ''])[1]; const thumb = (fm.match(/^thumbnail:[ \t]*["']?([^"'\n]+)/m)?.[1] ?? '').trim(); const heroSrc = (fm.match(/^hero:[\s\S]*?src:[ \t]*["']?([^"'\n,}]+)/m)?.[1] ?? '').trim(); return { file, path, text, fm, slug: file.replace(/\.md$/, ''), title: (fm.match(/^title:[ \t]*(.+)$/m)?.[1] ?? '').trim(), category: (fm.match(/^category:[ \t]*(\S+)/m)?.[1] ?? 'A').trim(), tags: (fm.match(/^tags:\n((?:[ \t]+-[ \t].+\n)+)/m)?.[1] ?? '').replace(/[ \t]+-[ \t]/g, ' '), // 카드에 실제로 쓰이는 값. PostCard 가 thumbnail 을 먼저 본다. current: thumb || heroSrc, }; }); // 홈 히어로·건물 사진과 같은 사진을 쓰는 글도 다시 배정한다. 홈 맨 위 사진이 바로 아래 카드에 또 나오면 중복으로 읽힌다. const shown = new Set([site.heroImage?.src, site.buildingImage?.src].filter(Boolean)); // 겹치는 글은 앞의 한 편만 두고 나머지를 다시 배정한다. const seen = new Set(), dup = new Set(); for (const p of posts) { if (!p.current) continue; if (seen.has(p.current)) dup.add(p.slug); else seen.add(p.current); } const kept = new Set(posts.filter((p) => p.current && !dup.has(p.slug) && !shown.has(p.current)).map((p) => p.current)); const targets = posts.filter((p) => !p.current || dup.has(p.slug) || shown.has(p.current)); // ---------- 후보 풀 ---------- const inGallery = new Set([...(site.insideGallery ?? []), ...(site.clinicGallery ?? [])].map((g) => g?.src).filter(Boolean)); // 글 분류(A 병원 소개·B 방문·예약·C 선택 기준·D 시술 정보·E 안전·마취)마다 어울리는 이미지 유형이 다르다. // 방문 안내에 시술 일러스트가 붙으면 주제가 어긋난다. 분류별로 선호 순서를 둔다. const PREF = { A: ['clinic', 'equipment', 'procedure'], B: ['clinic', 'equipment', 'procedure'], C: ['clinic', 'procedure', 'equipment'], D: ['procedure', 'equipment', 'clinic'], E: ['equipment', 'clinic', 'procedure'], F: ['clinic', 'procedure', 'equipment'], G: ['clinic', 'procedure', 'equipment'], }; const CATS = new Set(['procedure', 'equipment', 'clinic']); const pool = (manifest.items ?? []) .filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src)) .map((i) => ({ ...i, inGallery: inGallery.has(i.src) })); if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); } if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); } // ---------- 문구 ---------- function shortAlt(item) { const raw = String(item.alt || '').replace(/\s+/g, ' ').trim(); if (!raw) return `${clinicName} 홈페이지 이미지`.trim(); let s = raw.split(/(?<=[.!?])\s|(?=\s\d\s)/)[0].replace(/\s*\d+\s*$/, '').trim(); if (s.length > 60) s = s.slice(0, 58).replace(/\s\S*$/, '') + '…'; return s || `${clinicName} 홈페이지 이미지`.trim(); } function caption(item) { const t = String(item.page?.title || '').replace(/\s+/g, ' ').trim(); const generic = !t || t === clinicName || t.includes(clinicName) || t.length < 3; const where = generic ? `${clinicName} 홈페이지`.trim() : `${clinicName} 홈페이지 ${t}`.trim(); return `출처: ${where} (${item.capturedAt || manifest.capturedAt} 캡처)`; } // ---------- 배정 ---------- const words = (s) => String(s || '').toLowerCase().match(/[가-힣]{2,}|[a-z]{3,}/g) ?? []; function score(post, item) { const a = new Set(words(`${post.title} ${post.tags} ${post.slug}`)); let n = 0; for (const w of words(`${item.alt} ${item.src} ${item.page?.url}`)) if (a.has(w)) n += 1; return n; } const prefRank = (post, item) => { const order = PREF[post.category] ?? PREF.A; const i = order.indexOf(item.category); return (i < 0 ? order.length : i) + (item.inGallery ? 0.5 : 0); }; // 유형이 맞는 것을 먼저 쓰고, 같은 유형 안에서 낱말이 겹치는 짝을 먼저 확정한다. const pairs = []; for (const p of targets) for (const i of pool) pairs.push({ p, i, s: score(p, i), r: prefRank(p, i) }); pairs.sort((a, b) => (a.r - b.r) || (b.s - a.s) || a.p.slug.localeCompare(b.p.slug)); const usedPost = new Set(), usedImg = new Set(), chosen = new Map(); for (const { p, i, s } of pairs) { if (usedPost.has(p.slug) || usedImg.has(i.src)) continue; usedPost.add(p.slug); usedImg.add(i.src); chosen.set(p.slug, { item: i, s }); } // ---------- 쓰기 ---------- let wrote = 0; for (const p of targets) { const pick = chosen.get(p.slug); if (!pick) { console.log(` ${p.slug} ← (배정 없음 · 남은 이미지 부족)`); continue; } const { item } = pick; const block = `hero:\n src: ${item.src}\n alt: ${JSON.stringify(shortAlt(item))}\n caption: ${JSON.stringify(caption(item))}\nthumbnail: ${item.src}\n`; let next = p.text; const hadHero = HERO_BLOCK.test(next) || HERO_FLOW.test(next); next = next.replace(HERO_BLOCK, '').replace(HERO_FLOW, '').replace(THUMB_LINE, ''); if (hadHero || p.current) { // 있던 자리를 대신한다. description 다음 줄에 넣어 자리를 고정한다. const anchor = next.match(/^description:[ \t]*.+$/m); if (!anchor) { console.warn(` ! ${p.file}: description 줄이 없어 건너뜀`); continue; } next = next.replace(anchor[0], `${anchor[0]}\n${block.trimEnd()}`); } else { const anchor = next.match(/^description:[ \t]*.+$/m); if (!anchor) { console.warn(` ! ${p.file}: description 줄이 없어 건너뜀`); continue; } next = next.replace(anchor[0], `${anchor[0]}\n${block.trimEnd()}`); } if (!DRY) writeFileSync(p.path, next); wrote += 1; const why = dup.has(p.slug) ? '겹침 해소' : '새로 배정'; console.log(` ${p.slug} [${p.category}] ← ${item.src.replace(/^\/img\//, '')} (${item.category} · ${why}${pick.s ? ` · 낱말 일치 ${pick.s}` : ''})`); } console.log(`대표 이미지 ${wrote}편 배정 / 대상 ${targets.length}편 · 후보 ${pool.length}장 · 그대로 둔 글 ${posts.length - targets.length}편${DRY ? ' · dry' : ''}`);