haewon 육안 검토 결과: 원진 수집 이미지 66장 전부 사용 가능, 제외할 것 없음.
랜딩 히어로는 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 지정.
이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다.
alt 는 수집 원문("WJ 원진 소개 커버 이미지") 대신 무엇을 찍은 것인지 밝히는 문구로 쓴다.
갤러리에 실린 사진은 글 카드 후보에서 뺀다. 홈에서 글 목록 바로 아래가 갤러리라
카드 마지막 장과 갤러리 첫 장이 같으면 나란히 두 번 보인다.
다만 이렇게 빼서 글 수를 못 채우면 남는 글이 건물 사진으로 떨어져 더 나빠지므로,
후보가 글 수를 채울 수 있을 때만 뺀다.
결과
- 원진: 홈 13장 / 12종 · 최대 연속 1 (이전 9종 · 연속 2)
- 뷰: 홈 8장 / 4종 · 최대 연속 2. 쓸 수 있는 이미지가 3장뿐이라(건물 사진은 히어로)
5편 중 2편이 건물 사진으로 떨어진다. 병원 제공 시설 사진이 있어야 풀린다.
검증: 두 사이트 npm run build 종료코드 0, check.mjs 오류 0건, 게이트 41/41,
tsc 0 에러, 내보내기 0. 미배포.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 lines
10 KiB
JavaScript
165 lines
10 KiB
JavaScript
// 글마다 서로 다른 대표 이미지를 배정한다.
|
|
// node scripts/assign_post_heroes.mjs --site <siteDir> [--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']);
|
|
// 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면
|
|
// 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로
|
|
// 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다.
|
|
const base = (manifest.items ?? []).filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src));
|
|
// 갤러리에 실린 사진은 뺀다. 홈에서 글 목록 바로 아래가 갤러리라, 카드 마지막 장과 갤러리 첫 장이
|
|
// 같으면 나란히 두 번 보인다. 다만 이렇게 빼서 글 수를 못 채우면 더 나빠진다. 못 채운 글은 건물 사진으로
|
|
// 떨어져 같은 사진이 여러 장 생기기 때문이다. 그래서 전부 채울 수 있을 때만 뺀다.
|
|
const notInGallery = base.filter((i) => !inGallery.has(i.src));
|
|
const pool = (notInGallery.length >= targets.length ? notInGallery : base).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); }
|
|
|
|
// ---------- 문구 ----------
|
|
const EFFICACY = /개선|완화|제거|재생|촉진|강화|효과|없애|줄여|늘려|맑아|밝아|탄력|안전하게|빠른\s*회복|통증\s*없/;
|
|
// 수집기의 분류를 그대로 믿지 않는다. 일러스트가 시설(clinic)로 들어오는 경우가 있어
|
|
// 유형을 단정하는 문구 대신 출처만 밝힌다.
|
|
function shortAlt(item) {
|
|
const raw = String(item.alt || '').replace(/\s+/g, ' ').trim();
|
|
const fallback = [clinicName, '홈페이지 이미지'].filter(Boolean).join(' ');
|
|
if (!raw || EFFICACY.test(raw)) return fallback;
|
|
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 EFFICACY.test(s) ? fallback : (s || fallback);
|
|
}
|
|
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' : ''}`);
|