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>
This commit is contained in:
parent
4354d7fa6c
commit
dd754cf212
@ -7,5 +7,22 @@
|
|||||||
"areaLabel": "서울 강남 신논현역",
|
"areaLabel": "서울 강남 신논현역",
|
||||||
"founded": "2005",
|
"founded": "2005",
|
||||||
"urlEn": "https://www.viewplasticsurgery.com"
|
"urlEn": "https://www.viewplasticsurgery.com"
|
||||||
}
|
},
|
||||||
|
"excludeImages": [
|
||||||
|
"img02",
|
||||||
|
"stemcell_about_01",
|
||||||
|
"stemcell_count_01_mo",
|
||||||
|
"view-abdominoplasty",
|
||||||
|
"view-motiva-preserve-premium-standard"
|
||||||
|
],
|
||||||
|
"_excludeNote": "2026-09-12 육안 확인으로 제외. img02 는 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진이고, stemcell_about_01 은 팔에 주사·드레싱을 하는 시술 행위 사진이며, view-abdominoplasty 두 장과 view-motiva-preserve-premium-standard 두 장은 '이미지 광고 모델' 표기가 있는 모델 신체 사진, stemcell_count_01_mo 는 장비 제조사의 성능 주장 그래픽이다. 파일명과 alt 에 신호가 없어 자동 분류가 전부 시설(clinic)로 넣었다.",
|
||||||
|
"site": {
|
||||||
|
"heroImage": {
|
||||||
|
"srcMatch": "view-building_pc",
|
||||||
|
"alt": "뷰성형외과 건물 외관",
|
||||||
|
"width": 886,
|
||||||
|
"height": 885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_siteNote": "홈 히어로가 가슴 라인 일러스트로 잡혀 있었고 건물 사진의 alt 도 '가슴확대 시설 사진'으로 잘못 붙어 있었다. 건물 외관으로 고정한다."
|
||||||
}
|
}
|
||||||
|
|||||||
154
supporters/scripts/assign_post_heroes.mjs
Normal file
154
supporters/scripts/assign_post_heroes.mjs
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// 글마다 서로 다른 대표 이미지를 배정한다.
|
||||||
|
// 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' : ''}`);
|
||||||
131
supporters/scripts/build_image_review.mjs
Normal file
131
supporters/scripts/build_image_review.mjs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// 수집 이미지 육안 검토 페이지를 만든다.
|
||||||
|
// node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...]
|
||||||
|
//
|
||||||
|
// 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진·
|
||||||
|
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이
|
||||||
|
// 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고,
|
||||||
|
// 제외할 파일명을 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있게 목록으로 내준다.
|
||||||
|
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, resolve, dirname, basename } 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 sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []);
|
||||||
|
const OUT = resolve(opt('out', 'image-review.html'));
|
||||||
|
const ASSETS = join(dirname(OUT), 'image-review-files');
|
||||||
|
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
// 파일명·alt 로 걸러지는 것만 미리 표시한다. 나머지는 사람이 본다.
|
||||||
|
const FLAG = [
|
||||||
|
[/전후|비포|애프터|before|after|bna|b_a/i, '전후 사진 의심'],
|
||||||
|
[/모델|model/i, '광고 모델 의심'],
|
||||||
|
[/후기|리뷰|review|사례|case/i, '치료 경험담 의심'],
|
||||||
|
[/수상|award|인증|certif/i, '인증·수상 의심'],
|
||||||
|
[/vs|비교/i, '비교 광고 의심'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const spec of sites) {
|
||||||
|
const [id, dir] = spec.split('=');
|
||||||
|
const SITE = resolve(dir);
|
||||||
|
const DATA = join(SITE, 'src', 'data');
|
||||||
|
if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, items: [], note: 'images.json 없음 (수집된 이미지 없음)' }); continue; }
|
||||||
|
const manifest = readJson(join(DATA, 'images.json'));
|
||||||
|
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 usage = new Map();
|
||||||
|
const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); };
|
||||||
|
mark(site.heroImage?.src, '홈 히어로');
|
||||||
|
mark(site.buildingImage?.src, '건물 사진');
|
||||||
|
for (const g of site.insideGallery ?? []) mark(g?.src, '내부 갤러리');
|
||||||
|
for (const g of site.clinicGallery ?? []) mark(g?.src, '병원 갤러리');
|
||||||
|
const POSTS = join(SITE, 'src', 'content', 'posts');
|
||||||
|
if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
||||||
|
const t = readFileSync(join(POSTS, f), 'utf8');
|
||||||
|
for (const m of t.matchAll(/(?:thumbnail:|src:)\s*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 ${f.replace(/\.md$/, '')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(join(ASSETS, id), { recursive: true });
|
||||||
|
const items = (manifest.items ?? []).map((it) => {
|
||||||
|
const rel = it.src.replace(/^\//, '');
|
||||||
|
const from = join(SITE, 'public', rel);
|
||||||
|
const name = `${it.category}__${basename(it.src)}`;
|
||||||
|
if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name));
|
||||||
|
const hay = `${it.src} ${it.alt} ${it.page?.url ?? ''}`;
|
||||||
|
return {
|
||||||
|
...it, name,
|
||||||
|
flags: FLAG.filter(([re]) => re.test(hay)).map(([, label]) => label),
|
||||||
|
used: usage.get(it.src) ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
blocks.push({ id, items, clinic: fact.shortName || id, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = `
|
||||||
|
:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--ok:#1F7A4D;--bg:#fbfafd}
|
||||||
|
*{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:2.4rem 20px 4rem}
|
||||||
|
h1{font-size:1.7rem;margin:0 0 .4rem}h2{font-size:1.25rem;margin:2.6rem 0 .3rem;padding-top:1.4rem;border-top:1px solid var(--line)}
|
||||||
|
.lede{color:var(--muted);margin:0 0 1.6rem}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:1rem}
|
||||||
|
.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
|
||||||
|
.card.flagged{border-color:var(--bad);box-shadow:0 0 0 2px rgba(179,38,30,.10)}
|
||||||
|
.card img{width:100%;height:170px;object-fit:cover;background:#f1eff6;display:block}
|
||||||
|
.meta{padding:.7rem .8rem .9rem;font-size:.8rem}
|
||||||
|
.cat{display:inline-block;font-weight:700;font-size:.7rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
.fn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;word-break:break-all;color:var(--muted);margin:.25rem 0 .4rem}
|
||||||
|
.alt{color:var(--ink);margin:.2rem 0}
|
||||||
|
.use{margin-top:.45rem;font-size:.72rem;color:var(--ok)}
|
||||||
|
.flag{margin-top:.45rem;font-size:.74rem;color:var(--bad);font-weight:700}
|
||||||
|
.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;margin:1rem 0}
|
||||||
|
table{border-collapse:collapse;width:100%;font-size:.85rem}th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid var(--line)}
|
||||||
|
code{background:#f1eff6;padding:.1rem .3rem;border-radius:4px;font-size:.85em}
|
||||||
|
.none{color:var(--muted);padding:1rem 0}
|
||||||
|
@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:120px}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const body = blocks.map((b) => {
|
||||||
|
if (!b.items.length) return `<h2>${esc(b.clinic ?? b.id)}</h2><p class="none">${esc(b.note ?? '수집된 이미지 없음')}</p>`;
|
||||||
|
const cards = b.items.map((it) => `
|
||||||
|
<figure class="card${it.flags.length ? ' flagged' : ''}">
|
||||||
|
<img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(it.alt).slice(0, 120)}" loading="lazy">
|
||||||
|
<figcaption class="meta">
|
||||||
|
<span class="cat">${esc(it.category)}</span>
|
||||||
|
<div class="fn">${esc(basename(it.src))}</div>
|
||||||
|
<div class="alt">${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}</div>
|
||||||
|
${it.used.length ? `<div class="use">쓰이는 곳: ${esc(it.used.join(' · '))}</div>` : ''}
|
||||||
|
${it.flags.length ? `<div class="flag">${esc(it.flags.join(' · '))}</div>` : ''}
|
||||||
|
</figcaption>
|
||||||
|
</figure>`).join('');
|
||||||
|
const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · ');
|
||||||
|
return `<h2>${esc(b.clinic ?? b.id)} <span style="font-weight:400;color:var(--muted);font-size:.9rem">${esc(counts)} · ${esc(b.capturedAt ?? '')} 수집</span></h2>
|
||||||
|
<div class="grid">${cards}</div>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>서포터즈 수집 이미지 육안 검토</title><style>${style}</style></head><body><div class="wrap">
|
||||||
|
<h1>서포터즈 수집 이미지 육안 검토</h1>
|
||||||
|
<p class="lede">자동 수집이 분류한 이미지를 한 화면에 모았습니다. 빨간 테두리는 파일명·alt 로 걸러진 의심 항목이고, 그 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.</p>
|
||||||
|
<div class="sum">
|
||||||
|
<p style="margin:.2rem 0 .7rem"><strong>제외해야 하는 유형</strong> (docs/INFINITH_Supporters_Image_Rules.md)</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>유형</th><th>근거</th></tr>
|
||||||
|
<tr><td>전후 사진 · 경과 비교</td><td>의료광고 심의 기준. 동일 조건 촬영·무보정·동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다</td></tr>
|
||||||
|
<tr><td>환자 · 광고 모델 신체 사진</td><td>의료법 56조 2항 2호, 개인정보보호법·초상권</td></tr>
|
||||||
|
<tr><td>시술 행위 장면</td><td>의료법 56조 2항 6호. 수술실 공간 사진은 시설로 허용됩니다</td></tr>
|
||||||
|
<tr><td>인증 · 수상 마크</td><td>의료법 56조 2항 14호</td></tr>
|
||||||
|
<tr><td>타 병원 비교 표 · 그래프</td><td>의료법 56조 2항 4호</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:.9rem 0 0;color:var(--muted)">제외할 파일은 <code>supporters/briefs/<병원>/clinic.json</code> 의 <code>excludeImages</code> 에 파일명 앞부분을 넣으면 다음 빌드부터 빠집니다.</p>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div></body></html>`;
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
|
writeFileSync(OUT, html);
|
||||||
|
const flagged = blocks.reduce((n, b) => n + b.items.filter((i) => i.flags.length).length, 0);
|
||||||
|
const total = blocks.reduce((n, b) => n + b.items.length, 0);
|
||||||
|
console.log(`검토 페이지 → ${OUT} (이미지 ${total}장 · 자동 의심 ${flagged}장)`);
|
||||||
@ -9,7 +9,9 @@
|
|||||||
"gangnamunni": "https://www.gangnamunni.com/hospitals/125",
|
"gangnamunni": "https://www.gangnamunni.com/hospitals/125",
|
||||||
"factSheet": {
|
"factSheet": {
|
||||||
"kind": "피부과",
|
"kind": "피부과",
|
||||||
"areaLabel": "서울 강남 청담"
|
"areaLabel": "서울 강남 청담",
|
||||||
|
"name": "오라클피부과의원",
|
||||||
|
"shortName": "오라클피부과"
|
||||||
},
|
},
|
||||||
"site": {
|
"site": {
|
||||||
"heroImage": {
|
"heroImage": {
|
||||||
@ -41,5 +43,6 @@
|
|||||||
"sub_0"
|
"sub_0"
|
||||||
],
|
],
|
||||||
"_logoNote": "2026-09-10 haewon 지적(녹색 배너형 GIF는 완성도가 떨어짐) → 그룹 사이트 oraclemedicalgroup.com/images/logo_oracle_02.png (176×68, 투명 배경 워드마크)로 교체. 클리닉 사이트에는 CI 페이지가 없음(메뉴 CI소개가 빈 링크). 이전 배너형은 logo-horizontal.png 로 보존. haewon 제안(2026-09-10)대로 원본 배너에서 왼쪽 문구 영역을 잘라낸 logo-horizontal-cropped.png(133×49, 녹색 배경 유지)도 보관. 녹색 박스형을 쓰려면 logo 값을 이 파일로 바꾸고 --from youtube 재실행.",
|
"_logoNote": "2026-09-10 haewon 지적(녹색 배너형 GIF는 완성도가 떨어짐) → 그룹 사이트 oraclemedicalgroup.com/images/logo_oracle_02.png (176×68, 투명 배경 워드마크)로 교체. 클리닉 사이트에는 CI 페이지가 없음(메뉴 CI소개가 빈 링크). 이전 배너형은 logo-horizontal.png 로 보존. haewon 제안(2026-09-10)대로 원본 배너에서 왼쪽 문구 영역을 잘라낸 logo-horizontal-cropped.png(133×49, 녹색 배경 유지)도 보관. 녹색 박스형을 쓰려면 logo 값을 이 파일로 바꾸고 --from youtube 재실행.",
|
||||||
"_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외."
|
"_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외.",
|
||||||
}
|
"_factSheetNote": "상호 고정. 홈 제목 추정은 슬로건을 상호로 삼을 수 있다. 근거: supporters-oracle 기존 배포본 표기."
|
||||||
|
}
|
||||||
|
|||||||
28
templates/supporters-astro/briefs/viewclinic/clinic.json
Normal file
28
templates/supporters-astro/briefs/viewclinic/clinic.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"_comment": "병원별 고정값. 워커가 --name 등 인자보다 먼저 읽는다. factSheet 값은 뷰성형외과 홈페이지 표기와 대조해 확정한 것으로, 수작업 샘플 supporters/src/data/factSheet.json 과 같다. 홈 제목에서 상호를 추정하면 슬로건까지 들어가므로(\"안전을 최우선하는 뷰성형외과\") 여기서 고정한다.",
|
||||||
|
"factSheet": {
|
||||||
|
"name": "뷰성형외과의원",
|
||||||
|
"shortName": "뷰성형외과",
|
||||||
|
"kind": "성형외과",
|
||||||
|
"areaLabel": "서울 강남 신논현역",
|
||||||
|
"founded": "2005",
|
||||||
|
"urlEn": "https://www.viewplasticsurgery.com"
|
||||||
|
},
|
||||||
|
"excludeImages": [
|
||||||
|
"img02",
|
||||||
|
"stemcell_about_01",
|
||||||
|
"stemcell_count_01_mo",
|
||||||
|
"view-abdominoplasty",
|
||||||
|
"view-motiva-preserve-premium-standard"
|
||||||
|
],
|
||||||
|
"_excludeNote": "2026-09-12 육안 확인으로 제외. img02 는 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진이고, stemcell_about_01 은 팔에 주사·드레싱을 하는 시술 행위 사진이며, view-abdominoplasty 두 장과 view-motiva-preserve-premium-standard 두 장은 '이미지 광고 모델' 표기가 있는 모델 신체 사진, stemcell_count_01_mo 는 장비 제조사의 성능 주장 그래픽이다. 파일명과 alt 에 신호가 없어 자동 분류가 전부 시설(clinic)로 넣었다.",
|
||||||
|
"site": {
|
||||||
|
"heroImage": {
|
||||||
|
"srcMatch": "view-building_pc",
|
||||||
|
"alt": "뷰성형외과 건물 외관",
|
||||||
|
"width": 886,
|
||||||
|
"height": 885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_siteNote": "홈 히어로가 가슴 라인 일러스트로 잡혀 있었고 건물 사진의 alt 도 '가슴확대 시설 사진'으로 잘못 붙어 있었다. 건물 외관으로 고정한다."
|
||||||
|
}
|
||||||
@ -2,5 +2,11 @@
|
|||||||
"_comment": "병원별 고정값. 워커가 --youtube 등 인자보다 먼저 읽는다. haewon 결정 2026-09-08: 유튜브 기준 채널은 @wjwonjin (UC-EBM4e0VST1W_PQ0NzFMQg, 원장 설명 영상 736편). @wj2541(UCNBaQBCok5xIViCnrGdisoQ)은 브랜드 필름 채널이라 쓰지 않는다.",
|
"_comment": "병원별 고정값. 워커가 --youtube 등 인자보다 먼저 읽는다. haewon 결정 2026-09-08: 유튜브 기준 채널은 @wjwonjin (UC-EBM4e0VST1W_PQ0NzFMQg, 원장 설명 영상 736편). @wj2541(UCNBaQBCok5xIViCnrGdisoQ)은 브랜드 필름 채널이라 쓰지 않는다.",
|
||||||
"youtube": "UC-EBM4e0VST1W_PQ0NzFMQg",
|
"youtube": "UC-EBM4e0VST1W_PQ0NzFMQg",
|
||||||
"youtubeHandle": "@wjwonjin",
|
"youtubeHandle": "@wjwonjin",
|
||||||
"logo": "supporters/briefs/wonjin/logo-horizontal.svg"
|
"logo": "supporters/briefs/wonjin/logo-horizontal.svg",
|
||||||
|
"factSheet": {
|
||||||
|
"name": "원진성형외과의원",
|
||||||
|
"shortName": "원진성형외과",
|
||||||
|
"kind": "성형외과"
|
||||||
|
},
|
||||||
|
"_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다."
|
||||||
}
|
}
|
||||||
|
|||||||
154
templates/supporters-astro/scripts/assign_post_heroes.mjs
Normal file
154
templates/supporters-astro/scripts/assign_post_heroes.mjs
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// 글마다 서로 다른 대표 이미지를 배정한다.
|
||||||
|
// 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' : ''}`);
|
||||||
131
templates/supporters-astro/scripts/build_image_review.mjs
Normal file
131
templates/supporters-astro/scripts/build_image_review.mjs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// 수집 이미지 육안 검토 페이지를 만든다.
|
||||||
|
// node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...]
|
||||||
|
//
|
||||||
|
// 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진·
|
||||||
|
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 규칙(docs/INFINITH_Supporters_Image_Rules.md)이
|
||||||
|
// 금지하는 유형은 결국 사람이 봐야 갈린다. 이 페이지는 그 육안 확인을 한 화면에서 하게 하고,
|
||||||
|
// 제외할 파일명을 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있게 목록으로 내준다.
|
||||||
|
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, resolve, dirname, basename } 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 sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []);
|
||||||
|
const OUT = resolve(opt('out', 'image-review.html'));
|
||||||
|
const ASSETS = join(dirname(OUT), 'image-review-files');
|
||||||
|
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
// 파일명·alt 로 걸러지는 것만 미리 표시한다. 나머지는 사람이 본다.
|
||||||
|
const FLAG = [
|
||||||
|
[/전후|비포|애프터|before|after|bna|b_a/i, '전후 사진 의심'],
|
||||||
|
[/모델|model/i, '광고 모델 의심'],
|
||||||
|
[/후기|리뷰|review|사례|case/i, '치료 경험담 의심'],
|
||||||
|
[/수상|award|인증|certif/i, '인증·수상 의심'],
|
||||||
|
[/vs|비교/i, '비교 광고 의심'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const spec of sites) {
|
||||||
|
const [id, dir] = spec.split('=');
|
||||||
|
const SITE = resolve(dir);
|
||||||
|
const DATA = join(SITE, 'src', 'data');
|
||||||
|
if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, items: [], note: 'images.json 없음 (수집된 이미지 없음)' }); continue; }
|
||||||
|
const manifest = readJson(join(DATA, 'images.json'));
|
||||||
|
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 usage = new Map();
|
||||||
|
const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); };
|
||||||
|
mark(site.heroImage?.src, '홈 히어로');
|
||||||
|
mark(site.buildingImage?.src, '건물 사진');
|
||||||
|
for (const g of site.insideGallery ?? []) mark(g?.src, '내부 갤러리');
|
||||||
|
for (const g of site.clinicGallery ?? []) mark(g?.src, '병원 갤러리');
|
||||||
|
const POSTS = join(SITE, 'src', 'content', 'posts');
|
||||||
|
if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
||||||
|
const t = readFileSync(join(POSTS, f), 'utf8');
|
||||||
|
for (const m of t.matchAll(/(?:thumbnail:|src:)\s*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 ${f.replace(/\.md$/, '')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(join(ASSETS, id), { recursive: true });
|
||||||
|
const items = (manifest.items ?? []).map((it) => {
|
||||||
|
const rel = it.src.replace(/^\//, '');
|
||||||
|
const from = join(SITE, 'public', rel);
|
||||||
|
const name = `${it.category}__${basename(it.src)}`;
|
||||||
|
if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name));
|
||||||
|
const hay = `${it.src} ${it.alt} ${it.page?.url ?? ''}`;
|
||||||
|
return {
|
||||||
|
...it, name,
|
||||||
|
flags: FLAG.filter(([re]) => re.test(hay)).map(([, label]) => label),
|
||||||
|
used: usage.get(it.src) ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
blocks.push({ id, items, clinic: fact.shortName || id, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = `
|
||||||
|
:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--ok:#1F7A4D;--bg:#fbfafd}
|
||||||
|
*{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:2.4rem 20px 4rem}
|
||||||
|
h1{font-size:1.7rem;margin:0 0 .4rem}h2{font-size:1.25rem;margin:2.6rem 0 .3rem;padding-top:1.4rem;border-top:1px solid var(--line)}
|
||||||
|
.lede{color:var(--muted);margin:0 0 1.6rem}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:1rem}
|
||||||
|
.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
|
||||||
|
.card.flagged{border-color:var(--bad);box-shadow:0 0 0 2px rgba(179,38,30,.10)}
|
||||||
|
.card img{width:100%;height:170px;object-fit:cover;background:#f1eff6;display:block}
|
||||||
|
.meta{padding:.7rem .8rem .9rem;font-size:.8rem}
|
||||||
|
.cat{display:inline-block;font-weight:700;font-size:.7rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
.fn{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;word-break:break-all;color:var(--muted);margin:.25rem 0 .4rem}
|
||||||
|
.alt{color:var(--ink);margin:.2rem 0}
|
||||||
|
.use{margin-top:.45rem;font-size:.72rem;color:var(--ok)}
|
||||||
|
.flag{margin-top:.45rem;font-size:.74rem;color:var(--bad);font-weight:700}
|
||||||
|
.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;margin:1rem 0}
|
||||||
|
table{border-collapse:collapse;width:100%;font-size:.85rem}th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid var(--line)}
|
||||||
|
code{background:#f1eff6;padding:.1rem .3rem;border-radius:4px;font-size:.85em}
|
||||||
|
.none{color:var(--muted);padding:1rem 0}
|
||||||
|
@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:120px}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const body = blocks.map((b) => {
|
||||||
|
if (!b.items.length) return `<h2>${esc(b.clinic ?? b.id)}</h2><p class="none">${esc(b.note ?? '수집된 이미지 없음')}</p>`;
|
||||||
|
const cards = b.items.map((it) => `
|
||||||
|
<figure class="card${it.flags.length ? ' flagged' : ''}">
|
||||||
|
<img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(it.alt).slice(0, 120)}" loading="lazy">
|
||||||
|
<figcaption class="meta">
|
||||||
|
<span class="cat">${esc(it.category)}</span>
|
||||||
|
<div class="fn">${esc(basename(it.src))}</div>
|
||||||
|
<div class="alt">${esc(String(it.alt).slice(0, 110))}${String(it.alt).length > 110 ? '…' : ''}</div>
|
||||||
|
${it.used.length ? `<div class="use">쓰이는 곳: ${esc(it.used.join(' · '))}</div>` : ''}
|
||||||
|
${it.flags.length ? `<div class="flag">${esc(it.flags.join(' · '))}</div>` : ''}
|
||||||
|
</figcaption>
|
||||||
|
</figure>`).join('');
|
||||||
|
const counts = Object.entries(b.counts).map(([k, v]) => `${k} ${v}`).join(' · ');
|
||||||
|
return `<h2>${esc(b.clinic ?? b.id)} <span style="font-weight:400;color:var(--muted);font-size:.9rem">${esc(counts)} · ${esc(b.capturedAt ?? '')} 수집</span></h2>
|
||||||
|
<div class="grid">${cards}</div>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>서포터즈 수집 이미지 육안 검토</title><style>${style}</style></head><body><div class="wrap">
|
||||||
|
<h1>서포터즈 수집 이미지 육안 검토</h1>
|
||||||
|
<p class="lede">자동 수집이 분류한 이미지를 한 화면에 모았습니다. 빨간 테두리는 파일명·alt 로 걸러진 의심 항목이고, 그 밖의 금지 유형은 파일명에 신호가 없어 눈으로 보셔야 합니다.</p>
|
||||||
|
<div class="sum">
|
||||||
|
<p style="margin:.2rem 0 .7rem"><strong>제외해야 하는 유형</strong> (docs/INFINITH_Supporters_Image_Rules.md)</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>유형</th><th>근거</th></tr>
|
||||||
|
<tr><td>전후 사진 · 경과 비교</td><td>의료광고 심의 기준. 동일 조건 촬영·무보정·동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다</td></tr>
|
||||||
|
<tr><td>환자 · 광고 모델 신체 사진</td><td>의료법 56조 2항 2호, 개인정보보호법·초상권</td></tr>
|
||||||
|
<tr><td>시술 행위 장면</td><td>의료법 56조 2항 6호. 수술실 공간 사진은 시설로 허용됩니다</td></tr>
|
||||||
|
<tr><td>인증 · 수상 마크</td><td>의료법 56조 2항 14호</td></tr>
|
||||||
|
<tr><td>타 병원 비교 표 · 그래프</td><td>의료법 56조 2항 4호</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:.9rem 0 0;color:var(--muted)">제외할 파일은 <code>supporters/briefs/<병원>/clinic.json</code> 의 <code>excludeImages</code> 에 파일명 앞부분을 넣으면 다음 빌드부터 빠집니다.</p>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div></body></html>`;
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
|
writeFileSync(OUT, html);
|
||||||
|
const flagged = blocks.reduce((n, b) => n + b.items.filter((i) => i.flags.length).length, 0);
|
||||||
|
const total = blocks.reduce((n, b) => n + b.items.length, 0);
|
||||||
|
console.log(`검토 페이지 → ${OUT} (이미지 ${total}장 · 자동 의심 ${flagged}장)`);
|
||||||
@ -26,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url');
|
|||||||
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
|
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
|
||||||
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
|
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'publish', 'done'];
|
const PHASES = ['evidence', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'heroes', 'build', 'deploy', 'publish', 'done'];
|
||||||
const startFrom = opt('from', 'evidence');
|
const startFrom = opt('from', 'evidence');
|
||||||
|
|
||||||
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })();
|
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })();
|
||||||
@ -423,6 +423,15 @@ await phase('generate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 6. 빌드 + 게이트
|
// 6. 빌드 + 게이트
|
||||||
|
// 6a. 글 대표 이미지. 글마다 다른 사진을 배정한다. PostCard 의 대체 순서가 건물 사진 하나로 수렴해
|
||||||
|
// 목록에 같은 사진이 나란히 서는 것을 막는다. 의료진 사진은 쓰지 않고, 이미지가 모자라면 비워 둔다.
|
||||||
|
await phase('heroes', () => {
|
||||||
|
cpSync(join(SUP, 'scripts', 'assign_post_heroes.mjs'), join(SITE, 'scripts', 'assign_post_heroes.mjs'));
|
||||||
|
const r = run('node', ['scripts/assign_post_heroes.mjs', '--site', SITE], { cwd: SITE });
|
||||||
|
if (!r.ok) return { warn: true, summary: `대표 이미지 배정 실패, 기존 값으로 진행\n${tail(r.out, 3)}` };
|
||||||
|
return tail(r.out, 1);
|
||||||
|
});
|
||||||
|
|
||||||
await phase('build', () => {
|
await phase('build', () => {
|
||||||
const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE });
|
const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE });
|
||||||
if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`);
|
if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user