o2o-infinith-demo/supporters/scripts/assign_post_heroes.mjs
Haewon Kam dd754cf212 feat(supporters): 글마다 다른 대표 이미지 배정 · 뷰 수집 이미지에서 의료광고 금지 유형 제외
haewon 지적: 목록 화면에 같은 사진이 나란히 선다.

원인은 PostCard 의 대체 순서다. thumbnail·hero·영상이 모두 없으면 건물 사진 하나로
수렴한다. 뷰는 5편 전부, 오라클은 8편 전부가 그랬고, 원진은 6편 중 3편이 같은
소개 커버를 쓰고 있었다.

heroes 단계를 워커에 넣었다(generate 다음, build 앞). scripts/assign_post_heroes.mjs 가
images.json 에서 글마다 다른 사진을 고른다.
- 글 분류별로 어울리는 유형을 먼저 쓴다. 병원 소개·방문은 시설, 시술 정보는 시술 설명
  일러스트, 안전은 장비 순이다. 방문 안내에 시술 일러스트가 붙는 것을 막는다.
- 의료진 사진은 쓰지 않는다. 저자·검토자 표시가 지정 용도이고, 글 대표로 쓰면 그 원장이
  쓴 글로 읽힌다.
- 홈 히어로·건물 사진과 겹치는 글도 다시 배정한다.
- 수집 원문 alt 를 그대로 쓰지 않는다. 원문은 술기 설명 문단이라 고객 화면에 낼 수 없어
  첫 구절만 줄여 쓴다. 캡션의 출처도 페이지 제목이 슬로건일 때 상호로 바꾼다.
- 이미지가 글보다 적으면 남는 글은 비워 둔다. 다시 쓰면 처음 문제로 돌아간다.

작업 중 더 큰 문제를 찾았다. 뷰의 수집 이미지 중 다섯 장이 의료광고 금지 유형인데
전부 시설(clinic)로 분류돼 홈과 /clinic 에 나가고 있었다.
- img02: 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진
- stemcell_about_01: 팔에 주사·드레싱을 하는 시술 행위 사진
- view-abdominoplasty 2장, view-motiva-preserve-premium-standard 2장: "이미지 광고 모델" 표기가 있는 모델 신체 사진
- stemcell_count_01_mo: 장비 제조사의 성능 주장 그래픽
파일명과 alt 에 아무 신호가 없어 규칙 문서가 예고한 파일명·alt 검사로는 잡히지 않는다.
briefs/viewclinic/clinic.json 의 excludeImages 로 뺐고, 홈 히어로가 가슴 라인
일러스트였던 것과 건물 사진 alt 가 "가슴확대 시설 사진"이던 것도 함께 고정했다.

육안 검토용 scripts/build_image_review.mjs 를 만들었다. 세 병원 수집 이미지를 한 화면에
모아 분류·쓰이는 곳·의심 표시를 보여준다. 91장 중 파일명·alt 로 걸러진 것은 0장이었다.
이 유형은 사람이 봐야 갈린다는 뜻이다.

결과: 홈·목록의 연속 중복이 뷰 1, 원진 1 로 내려갔다(이전 뷰 6, 원진 3).
오라클은 수집 이미지가 0장이라 배정할 것이 없다. 근거의 이미지 alt 가 EUC-KR 에서
깨져(????) 분류기가 하나도 고르지 못하는 것이 원인으로 좁혀졌으나, 병원 사이트가
응답하지 않아 재수집·검증을 하지 못했다.

검증: 게이트 41/41, check.mjs 오류 0건, tsc 0 에러, 내보내기 종료코드 0. 모두 미배포.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 16:15:52 +09:00

155 lines
8.9 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']);
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' : ''}`);