뷰 배포가 Vercel 빌드에서 멈췄다. 워커는 통과했는데 Vercel 이 막힌 이유는 --from deploy 가 빌드 게이트를 건너뛰고 바로 배포로 갔고, Vercel 은 자체 npm run build (astro build && check.mjs)를 돌리기 때문이다. 실패는 두 가지였다. 1. HOME_DUPLICATE: 검토자 표시줄의 원장 이력이 병원 홈페이지 문장과 40자 일치했다. 이 자리는 병원 의료진 페이지의 이력을 프로필 링크와 함께 그대로 옮기는 귀속 표시라 설계상 일치한다. 다른 말로 바꿔 쓰면 이력이 사실과 달라진다. /stay 의 인용 블록(#recovery-quote)을 이미 같은 이유로 빼고 있어서, 같은 규칙으로 .doctor-strip 을 QUOTED_BLOCKS 에 넣었다. 금칙어·운영자 어휘 검사는 그대로 적용된다. 2. 대표 이미지 alt 의 효과 주장: 배정된 alt 가 "손상된 피부 세포 재생과 콜라겐 생성으로 흉터와 색소침착 개선" 처럼 결과를 주장했다. 의료법 56조 2항 8호 대상이고 주제도 맞지 않았다. 이미지 자체는 문제가 없으므로 빼지 않고, 효과·결과 어휘가 있으면 출처만 밝히는 중립 문구로 바꾼다. 수집기의 분류를 믿지 않는다(일러스트가 시설로 들어온다)는 이유로 "시설 사진" 처럼 유형을 단정하지 않고 "홈페이지 이미지"로 쓴다. 검증: 세 사이트 모두 npm run build 종료코드 0(Vercel 이 돌리는 명령과 같다). 게이트 41/41, 샘플 check.mjs 오류 0건, tsc 0 에러, 내보내기 0. 연속 중복은 뷰 1·원진 1 이고, 오라클은 수집 이미지가 없어 그대로다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
9.6 KiB
JavaScript
162 lines
9.6 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 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); }
|
|
|
|
// ---------- 문구 ----------
|
|
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' : ''}`);
|