feat(supporters): 유튜브·로고 단계 — collect_youtube.mjs(API 집계·정보형 필터·쇼츠 판별), 워커 youtube 단계(로고·파비콘·homeVideos), 글↔영상 매칭, 카드 로고 대체, 기존 글 보존 재실행. 원진 재배포
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
572978c316
commit
eb873bc43a
@ -292,6 +292,16 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
|
||||
- 원진 글의 특징: 시술 페이지에 없는 칸은 표에 "확인"으로 남고(눈성형 표의 쌍꺼풀·눈매교정 행 전부 확인), 근거가 있는 행(CFS 2시간·7일·3일, 오픈아이 40분·5~7일)만 값이 있다. 이것이 의도한 동작이다. 리포트 `docs/reports/viewclinic/04_supporters/Wonjin_Supporters_Worker_E2E_2026-09-07.md`.
|
||||
- 남은 것: 유튜브 핸들 발견·조회수 집계·뉴스룸·감사 재실행을 워커 단계로 추가. 원진은 유튜브 채널 3개가 registry 에 있으므로 첫 후보. launchd 폴러 설치는 haewon 확인 후.
|
||||
|
||||
### 11-10. 유튜브·로고 단계 (v1 §3 Phase 2 일부) · 2026-09-08
|
||||
- haewon 지적: 원진 글 카드에 검은 도형(Astro 기본 파비콘)이 보임. 원인은 워커가 이미지·영상 자산을 아직 안 붙이고 카드가 파비콘으로 대체하던 것.
|
||||
- `supporters/scripts/collect_youtube.mjs --channel <UC…|@handle|URL>`: YouTube Data API 로 uploads 전량 → videos.list 배치 → 쇼츠 판별(/shorts/{id} HEAD 200, 상위 120편, 제목 #shorts 포함 시 쇼츠) → viewclinic 과 같은 videos.json(channel·top·topLong·topShorts·shortsInfo·all). 정보형 판정 = NOT_INFO(후기·전후·토크·브이로그·브랜드 필름·연예인·트렌드·변화 등) + 게이트 BANNED_VIDEO_TITLE + checkBannedBody(제목). 설명형 롱폼은 90초 이상. `all[].info` 로 판정을 남긴다. shortsInfo.answer 는 비워 둔다(사람이 채움).
|
||||
- 워커 `youtube` 단계(data 다음): `--logo <svg|png>` → public/img/logo.*, site.logo/logoFoot, 파비콘(SVG 는 64×64 흰 배경에 중앙 배치한 favicon.svg). `--youtube <채널>` 없으면 근거 링크에서 자동 발견. site.homeVideos(topLong 3편), factSheet.surfaces.youtube 채움. 오늘 집계본이 있으면 재사용.
|
||||
- 글↔영상: `default_briefs.mjs` 가 정보형 롱폼 중 제목이 글 주제 키워드(시술 영역 라벨·안전·상담 등)와 겹치는 것 최대 3편을 brief.videos 로. 병원 소개·방문 안내는 영상을 붙이지 않는다. 워커 generate 단계는 기존 글이 있으면 재생성하지 않고 brief 의 영상만 frontmatter 에 덮어쓴다(`--regenerate` 로 재생성). data 단계 재실행 시 글·videos.json 보존.
|
||||
- 카드 대체: PostCard 는 썸네일 → 히어로 → 첫 영상 → 건물 사진 → 병원 로고(`.thumb-logo`, contain) 순. 프레임워크 파비콘은 쓰지 않는다.
|
||||
- 게이트가 잡은 것: 설명 영상 제목 "부작용은 없을까?" 가 BANNED_PHRASE 로 홈·영상 페이지에서 차단 → 수집 단계에서 같은 규칙으로 거르게 수정.
|
||||
- 원진 결과: 채널 736편 · 설명형 롱폼 10 · 쇼츠 10 · 글 2편에 영상 3편씩(눈성형, 상담 질문), 나머지 4편은 로고 카드 · 헤더 로고·파비콘 = 원진 로고(`supporters/briefs/wonjin/logo-horizontal.svg`). https://supporters-wonjin.vercel.app 재배포. 뷰성형외과 회귀: 게이트 16/16, 빌드 오류 0.
|
||||
- 남은 것: 홈페이지 이미지(전후 제외) 히어로·갤러리, 뉴스룸, 감사 재실행. 쇼츠 정리 답은 사람 입력.
|
||||
|
||||
---
|
||||
|
||||
## 12. haewon 결정 반영과 제안 (2026-09-07 세션 2)
|
||||
|
||||
1
supporters/briefs/wonjin/logo-horizontal.svg
Normal file
1
supporters/briefs/wonjin/logo-horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.7 KiB |
100
supporters/scripts/collect_youtube.mjs
Normal file
100
supporters/scripts/collect_youtube.mjs
Normal file
@ -0,0 +1,100 @@
|
||||
// 유튜브 채널 집계 (v1 §4 "유튜브 통계·쇼츠 판별" 이식). YouTube Data API v3 만 쓴다(자막 없음).
|
||||
// node scripts/collect_youtube.mjs --channel <UC... | @handle | 채널 URL> [--out src/data/videos.json] [--max 2000]
|
||||
// 산출: viewclinic videos.json 과 같은 형태.
|
||||
// channel{id,handle,url,title,subscribers,videos,totalViews} · top(전체 조회수 10) · topLong(정보형 롱폼 10) · topShorts(정보형 쇼츠 10)
|
||||
// shortsInfo(정보형 쇼츠 10, answer 는 사람이 채운다) · all(전체, 조회수순) · fetchedAt · shortsDetection
|
||||
// 정보형 판별: 제목에 후기·전후·토크·변신·이벤트 류가 없는 것(게이트 BANNED_VIDEO_TITLE + 확장). 쇼츠 판별: youtube.com/shorts/{id} 가 200 이면 쇼츠(길이 기준 아님, v1 §6).
|
||||
import { writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as R from './gate/rules.mjs';
|
||||
|
||||
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 here = fileURLToPath(new URL('.', import.meta.url));
|
||||
(function loadEnv() { let dir = here; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) 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, ''); } dir = dirname(dir); } })();
|
||||
const KEY = process.env.YOUTUBE_API_KEY;
|
||||
if (!KEY) { console.error('YOUTUBE_API_KEY 없음'); process.exit(2); }
|
||||
const input = opt('channel'); if (!input) { console.error('--channel 필요'); process.exit(2); }
|
||||
const OUT = resolve(opt('out', join(here, '../src/data/videos.json')));
|
||||
const MAX = Number(opt('max', '2000'));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 정보형이 아닌 제목 (게이트 BANNED_VIDEO_TITLE 과 같은 계열 + 확장). 후기·경험담·토크·이벤트는 사이트 원칙(§3-8)상 싣지 않는다.
|
||||
const NOT_INFO = /전후|리뷰|후기|성공|번호따|변신|역대급|토크|브이로그|vlog|먹방|챌린지|이벤트|할인|특가|메이크오버|make\s?over|비포|애프터|before|after|실화|대박|충격|ㅋㅋ|😱|🔥|브랜드\s?필름|CF|광고|메이킹|연예인|인플루언서|아이돌|모음|트렌드|테스트|망한|반전|응원|캠페인|썰|레전드|처럼|의혹|결심한 이유|효과는 대단|리얼|스토리|공개|달라진|생기는 일|댓글|만뷰|변화|개월 후|년 후|💎|💞|👑/i;
|
||||
const MIN_LONG_SEC = 90; // 설명형 롱폼은 90초 이상 (짧은 홍보 영상 제외)
|
||||
|
||||
const api = async (path, params) => {
|
||||
const u = new URL(`https://www.googleapis.com/youtube/v3/${path}`); for (const [k, v] of Object.entries({ ...params, key: KEY })) u.searchParams.set(k, v);
|
||||
const r = await fetch(u); const j = await r.json(); if (!r.ok) throw new Error(`${path} ${r.status} ${JSON.stringify(j).slice(0, 200)}`); return j;
|
||||
};
|
||||
|
||||
// 채널 해석: UC id / @handle / URL
|
||||
async function resolveChannel(s) {
|
||||
let id = null, handle = null;
|
||||
const m = s.match(/(UC[\w-]{20,})/); if (m) id = m[1];
|
||||
const h = s.match(/@([\w.-]+)/); if (h) handle = h[1];
|
||||
const params = id ? { id } : handle ? { forHandle: handle } : null;
|
||||
if (!params) throw new Error(`채널 식별 불가: ${s}`);
|
||||
const j = await api('channels', { part: 'snippet,statistics,contentDetails', ...params });
|
||||
const c = j.items?.[0]; if (!c) throw new Error(`채널 없음: ${s}`);
|
||||
return c;
|
||||
}
|
||||
const c = await resolveChannel(input);
|
||||
const uploads = c.contentDetails.relatedPlaylists.uploads;
|
||||
console.log(`채널 ${c.snippet.title} (${c.snippet.customUrl}) · 영상 ${c.statistics.videoCount} · 조회수 ${c.statistics.viewCount}`);
|
||||
|
||||
// uploads 전량
|
||||
const ids = []; let pageToken;
|
||||
do {
|
||||
const j = await api('playlistItems', { part: 'contentDetails', playlistId: uploads, maxResults: 50, ...(pageToken ? { pageToken } : {}) });
|
||||
for (const it of j.items ?? []) ids.push(it.contentDetails.videoId);
|
||||
pageToken = j.nextPageToken;
|
||||
process.stdout.write(`\r 목록 ${ids.length}`);
|
||||
} while (pageToken && ids.length < MAX);
|
||||
console.log();
|
||||
|
||||
// videos.list 50개 배치
|
||||
const iso = (d) => { const m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); return m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0; };
|
||||
const all = [];
|
||||
for (let i = 0; i < ids.length; i += 50) {
|
||||
const j = await api('videos', { part: 'snippet,statistics,contentDetails,status', id: ids.slice(i, i + 50).join(',') });
|
||||
for (const v of j.items ?? []) {
|
||||
if (v.status?.privacyStatus !== 'public') continue;
|
||||
all.push({ id: v.id, title: v.snippet.title, published: v.snippet.publishedAt.slice(0, 10), views: Number(v.statistics.viewCount ?? 0), likes: v.statistics.likeCount != null ? Number(v.statistics.likeCount) : null, comments: v.statistics.commentCount != null ? Number(v.statistics.commentCount) : null, duration: iso(v.contentDetails.duration), embeddable: v.status.embeddable, privacy: v.status.privacyStatus, description: (v.snippet.description ?? '').slice(0, 300) });
|
||||
}
|
||||
process.stdout.write(`\r 상세 ${all.length}/${ids.length}`);
|
||||
}
|
||||
console.log();
|
||||
all.sort((a, b) => b.views - a.views);
|
||||
|
||||
// 쇼츠 판별: 상위 후보(길이 3분 이하) 중 조회수 상위 120개만 HEAD 확인 (요청 수 절약). 그 밖은 60초 이하를 쇼츠로 본다.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function isShort(id) {
|
||||
try { const r = await fetch(`https://www.youtube.com/shorts/${id}`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(10000) }); return r.status === 200; } catch { return null; }
|
||||
}
|
||||
const cand = all.filter((v) => v.duration <= 180).slice(0, 120);
|
||||
let checked = 0;
|
||||
for (const v of cand) { const s = await isShort(v.id); v.isShort = s === null ? v.duration <= 60 : s; checked++; await sleep(120); process.stdout.write(`\r 쇼츠 판별 ${checked}/${cand.length}`); }
|
||||
for (const v of all) if (v.isShort === undefined) v.isShort = v.duration <= 60;
|
||||
for (const v of all) if (/#\s?shorts/i.test(v.title)) v.isShort = true; // 제목에 #shorts 가 있으면 쇼츠로 본다
|
||||
console.log();
|
||||
|
||||
// 정보형 + 발행 게이트 통과(영상 제목 금칙·본문 금칙 표현) 둘 다 만족해야 사이트에 싣는다. all[].info 로도 남겨 다른 스크립트(default_briefs)가 같은 판정을 쓴다.
|
||||
const info = (v) => !NOT_INFO.test(v.title) && !R.BANNED_VIDEO_TITLE.test(v.title) && R.checkBannedBody(v.title).length === 0;
|
||||
for (const v of all) v.info = info(v);
|
||||
const strip = ({ description, ...v }) => v;
|
||||
const out = {
|
||||
fetchedAt: today,
|
||||
channel: { id: c.id, handle: c.snippet.customUrl, url: `https://www.youtube.com/${c.snippet.customUrl ?? 'channel/' + c.id}`, title: c.snippet.title, subscribers: fmtKo(Number(c.statistics.subscriberCount)), videos: Number(c.statistics.videoCount), totalViews: Number(c.statistics.viewCount) },
|
||||
top: all.slice(0, 10),
|
||||
topLong: all.filter((v) => !v.isShort && v.duration >= MIN_LONG_SEC && info(v) && v.embeddable !== false).slice(0, 10).map(strip),
|
||||
topShorts: all.filter((v) => v.isShort && info(v)).slice(0, 10).map(strip),
|
||||
shortsInfo: all.filter((v) => v.isShort && info(v)).slice(0, 10).map((v) => ({ ...strip(v), answer: '' })),
|
||||
all: all.map(strip),
|
||||
shortsDetection: `youtube.com/shorts/{id} HEAD 200 (상위 ${cand.length}편 확인, 나머지는 60초 이하), ${today}`,
|
||||
note: 'topLong·topShorts 는 정보형(후기·전후·토크·이벤트 제목 제외). shortsInfo.answer 는 사람이 영상을 보고 채운다(자동 생성하지 않음).',
|
||||
};
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 1));
|
||||
console.log(`저장 ${OUT} · 전체 ${all.length} · 롱폼 정보형 ${out.topLong.length} · 쇼츠 정보형 ${out.topShorts.length}`);
|
||||
function fmtKo(n) { return n >= 10000 ? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}만` : n.toLocaleString('ko-KR'); }
|
||||
@ -89,7 +89,7 @@ for (const a of topAreas) add({
|
||||
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'],
|
||||
title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`,
|
||||
intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`,
|
||||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep,
|
||||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, area: a,
|
||||
});
|
||||
if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({
|
||||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||||
@ -98,10 +98,28 @@ if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >=
|
||||
pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep,
|
||||
});
|
||||
|
||||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, ...c }) => ({
|
||||
// 글 ↔ 원장 영상 매칭: 사이트 데이터 videos.json(collect_youtube.mjs 산출)의 정보형 롱폼 중 제목이 글 주제 키워드와 겹치는 것 최대 3편.
|
||||
// 영상은 "이어서 볼 영상"과 카드 썸네일로만 쓴다. 자막이 없으므로 발언은 인용하지 않는다(§4-2).
|
||||
const videosPath = join(SITE, 'src/data/videos.json');
|
||||
const vdata = existsSync(videosPath) ? JSON.parse(readFileSync(videosPath, 'utf8')) : null;
|
||||
// 후보 = 정보형(info) 롱폼(쇼츠 아님, 90초 이상, 제목에 #shorts 없음)만. collect_youtube 가 붙인 info 판정을 그대로 쓴다.
|
||||
const longs = (vdata?.topLong ?? []).concat((vdata?.all ?? []).filter((v) => !v.isShort && v.duration >= 90 && v.info !== false && !/#\s?shorts/i.test(v.title))).filter((v, i, a) => a.findIndex((x) => x.id === v.id) === i);
|
||||
// 병원 소개·방문 안내는 영상을 붙이지 않는다(주제와 맞는 영상이 없고 아무 영상이나 붙으면 오해를 준다)
|
||||
const KW = {
|
||||
'what-is-clinic': [], 'visit-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진'],
|
||||
'precautions-guide': ['주의사항', '수술 후', '관리', '실밥', '부기', '붓기'], 'aftercare-program': ['관리', '애프터', '회복', '수술 후'], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
|
||||
};
|
||||
const areaKw = (a) => { const t = areaLabel(a); return [t, ...t.split(/[·,/\s]+/).filter((w) => w.length >= 2), a.area]; };
|
||||
function matchVideos(c) {
|
||||
if (!longs.length) return [];
|
||||
const kws = (KW[c.id] ?? []).concat(c.area ? areaKw(c.area) : []).filter(Boolean);
|
||||
if (!kws.length) return [];
|
||||
return longs.filter((v) => kws.some((k) => v.title.includes(k))).slice(0, 3).map((v) => ({ id: v.id, title: v.title, published: v.published }));
|
||||
}
|
||||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => ({
|
||||
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
|
||||
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
|
||||
videos: [], gallery: [],
|
||||
videos: matchVideos({ ...c, area }), gallery: [],
|
||||
}));
|
||||
|
||||
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
|
||||
|
||||
@ -4,11 +4,13 @@ interface Props { post: any }
|
||||
const { post } = Astro.props;
|
||||
const d = post.data;
|
||||
const v0 = d.videos?.[0] ?? d.video;
|
||||
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? '/favicon.svg');
|
||||
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? `${fact.shortName} 건물 외관`);
|
||||
// 썸네일 우선순위: 지정 썸네일 → 히어로 → 첫 영상 → 건물 사진 → 병원 로고. 전부 없으면 이미지 없이 낸다(프레임워크 기본 파비콘을 쓰지 않는다).
|
||||
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? S.logo?.src ?? null);
|
||||
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? S.logo?.alt ?? `${fact.shortName} 로고`);
|
||||
const isLogo = !d.thumbnail && !d.hero?.src && !v0 && !S.buildingImage?.src && Boolean(S.logo?.src);
|
||||
---
|
||||
<article class="card has-thumb">
|
||||
<a class="thumb" href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>
|
||||
<article class={`card ${thumb ? 'has-thumb' : ''}`}>
|
||||
{thumb && <a class={`thumb ${isLogo ? 'thumb-logo' : ''}`} href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>}
|
||||
<div class="body">
|
||||
<div class="cat">{d.category} · {CATEGORY_LABELS[d.category]}</div>
|
||||
<h3><a href={`/posts/${post.id}`}>{d.title}</a></h3>
|
||||
|
||||
@ -239,6 +239,8 @@ td { color: var(--slate-700); }
|
||||
.figure.portrait img { aspect-ratio: auto; }
|
||||
.card.has-thumb { padding: 0; overflow: hidden; }
|
||||
.card.has-thumb .thumb img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
|
||||
.card.has-thumb .thumb.thumb-logo { display: block; background: #F7F8FC; aspect-ratio: 16 / 9; }
|
||||
.card.has-thumb .thumb.thumb-logo img { object-fit: contain; padding: 18% 22%; box-sizing: border-box; }
|
||||
.card.has-thumb .body { padding: 1.2rem 1.4rem 1.4rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||
.card.has-thumb .body p { flex: 1; }
|
||||
.doctor-strip { display: flex; gap: 1rem; align-items: center; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; margin: 1.4rem 0; box-shadow: var(--shadow); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.7 KiB |
100
templates/supporters-astro/scripts/collect_youtube.mjs
Normal file
100
templates/supporters-astro/scripts/collect_youtube.mjs
Normal file
@ -0,0 +1,100 @@
|
||||
// 유튜브 채널 집계 (v1 §4 "유튜브 통계·쇼츠 판별" 이식). YouTube Data API v3 만 쓴다(자막 없음).
|
||||
// node scripts/collect_youtube.mjs --channel <UC... | @handle | 채널 URL> [--out src/data/videos.json] [--max 2000]
|
||||
// 산출: viewclinic videos.json 과 같은 형태.
|
||||
// channel{id,handle,url,title,subscribers,videos,totalViews} · top(전체 조회수 10) · topLong(정보형 롱폼 10) · topShorts(정보형 쇼츠 10)
|
||||
// shortsInfo(정보형 쇼츠 10, answer 는 사람이 채운다) · all(전체, 조회수순) · fetchedAt · shortsDetection
|
||||
// 정보형 판별: 제목에 후기·전후·토크·변신·이벤트 류가 없는 것(게이트 BANNED_VIDEO_TITLE + 확장). 쇼츠 판별: youtube.com/shorts/{id} 가 200 이면 쇼츠(길이 기준 아님, v1 §6).
|
||||
import { writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as R from './gate/rules.mjs';
|
||||
|
||||
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 here = fileURLToPath(new URL('.', import.meta.url));
|
||||
(function loadEnv() { let dir = here; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) 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, ''); } dir = dirname(dir); } })();
|
||||
const KEY = process.env.YOUTUBE_API_KEY;
|
||||
if (!KEY) { console.error('YOUTUBE_API_KEY 없음'); process.exit(2); }
|
||||
const input = opt('channel'); if (!input) { console.error('--channel 필요'); process.exit(2); }
|
||||
const OUT = resolve(opt('out', join(here, '../src/data/videos.json')));
|
||||
const MAX = Number(opt('max', '2000'));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 정보형이 아닌 제목 (게이트 BANNED_VIDEO_TITLE 과 같은 계열 + 확장). 후기·경험담·토크·이벤트는 사이트 원칙(§3-8)상 싣지 않는다.
|
||||
const NOT_INFO = /전후|리뷰|후기|성공|번호따|변신|역대급|토크|브이로그|vlog|먹방|챌린지|이벤트|할인|특가|메이크오버|make\s?over|비포|애프터|before|after|실화|대박|충격|ㅋㅋ|😱|🔥|브랜드\s?필름|CF|광고|메이킹|연예인|인플루언서|아이돌|모음|트렌드|테스트|망한|반전|응원|캠페인|썰|레전드|처럼|의혹|결심한 이유|효과는 대단|리얼|스토리|공개|달라진|생기는 일|댓글|만뷰|변화|개월 후|년 후|💎|💞|👑/i;
|
||||
const MIN_LONG_SEC = 90; // 설명형 롱폼은 90초 이상 (짧은 홍보 영상 제외)
|
||||
|
||||
const api = async (path, params) => {
|
||||
const u = new URL(`https://www.googleapis.com/youtube/v3/${path}`); for (const [k, v] of Object.entries({ ...params, key: KEY })) u.searchParams.set(k, v);
|
||||
const r = await fetch(u); const j = await r.json(); if (!r.ok) throw new Error(`${path} ${r.status} ${JSON.stringify(j).slice(0, 200)}`); return j;
|
||||
};
|
||||
|
||||
// 채널 해석: UC id / @handle / URL
|
||||
async function resolveChannel(s) {
|
||||
let id = null, handle = null;
|
||||
const m = s.match(/(UC[\w-]{20,})/); if (m) id = m[1];
|
||||
const h = s.match(/@([\w.-]+)/); if (h) handle = h[1];
|
||||
const params = id ? { id } : handle ? { forHandle: handle } : null;
|
||||
if (!params) throw new Error(`채널 식별 불가: ${s}`);
|
||||
const j = await api('channels', { part: 'snippet,statistics,contentDetails', ...params });
|
||||
const c = j.items?.[0]; if (!c) throw new Error(`채널 없음: ${s}`);
|
||||
return c;
|
||||
}
|
||||
const c = await resolveChannel(input);
|
||||
const uploads = c.contentDetails.relatedPlaylists.uploads;
|
||||
console.log(`채널 ${c.snippet.title} (${c.snippet.customUrl}) · 영상 ${c.statistics.videoCount} · 조회수 ${c.statistics.viewCount}`);
|
||||
|
||||
// uploads 전량
|
||||
const ids = []; let pageToken;
|
||||
do {
|
||||
const j = await api('playlistItems', { part: 'contentDetails', playlistId: uploads, maxResults: 50, ...(pageToken ? { pageToken } : {}) });
|
||||
for (const it of j.items ?? []) ids.push(it.contentDetails.videoId);
|
||||
pageToken = j.nextPageToken;
|
||||
process.stdout.write(`\r 목록 ${ids.length}`);
|
||||
} while (pageToken && ids.length < MAX);
|
||||
console.log();
|
||||
|
||||
// videos.list 50개 배치
|
||||
const iso = (d) => { const m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); return m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0; };
|
||||
const all = [];
|
||||
for (let i = 0; i < ids.length; i += 50) {
|
||||
const j = await api('videos', { part: 'snippet,statistics,contentDetails,status', id: ids.slice(i, i + 50).join(',') });
|
||||
for (const v of j.items ?? []) {
|
||||
if (v.status?.privacyStatus !== 'public') continue;
|
||||
all.push({ id: v.id, title: v.snippet.title, published: v.snippet.publishedAt.slice(0, 10), views: Number(v.statistics.viewCount ?? 0), likes: v.statistics.likeCount != null ? Number(v.statistics.likeCount) : null, comments: v.statistics.commentCount != null ? Number(v.statistics.commentCount) : null, duration: iso(v.contentDetails.duration), embeddable: v.status.embeddable, privacy: v.status.privacyStatus, description: (v.snippet.description ?? '').slice(0, 300) });
|
||||
}
|
||||
process.stdout.write(`\r 상세 ${all.length}/${ids.length}`);
|
||||
}
|
||||
console.log();
|
||||
all.sort((a, b) => b.views - a.views);
|
||||
|
||||
// 쇼츠 판별: 상위 후보(길이 3분 이하) 중 조회수 상위 120개만 HEAD 확인 (요청 수 절약). 그 밖은 60초 이하를 쇼츠로 본다.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function isShort(id) {
|
||||
try { const r = await fetch(`https://www.youtube.com/shorts/${id}`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(10000) }); return r.status === 200; } catch { return null; }
|
||||
}
|
||||
const cand = all.filter((v) => v.duration <= 180).slice(0, 120);
|
||||
let checked = 0;
|
||||
for (const v of cand) { const s = await isShort(v.id); v.isShort = s === null ? v.duration <= 60 : s; checked++; await sleep(120); process.stdout.write(`\r 쇼츠 판별 ${checked}/${cand.length}`); }
|
||||
for (const v of all) if (v.isShort === undefined) v.isShort = v.duration <= 60;
|
||||
for (const v of all) if (/#\s?shorts/i.test(v.title)) v.isShort = true; // 제목에 #shorts 가 있으면 쇼츠로 본다
|
||||
console.log();
|
||||
|
||||
// 정보형 + 발행 게이트 통과(영상 제목 금칙·본문 금칙 표현) 둘 다 만족해야 사이트에 싣는다. all[].info 로도 남겨 다른 스크립트(default_briefs)가 같은 판정을 쓴다.
|
||||
const info = (v) => !NOT_INFO.test(v.title) && !R.BANNED_VIDEO_TITLE.test(v.title) && R.checkBannedBody(v.title).length === 0;
|
||||
for (const v of all) v.info = info(v);
|
||||
const strip = ({ description, ...v }) => v;
|
||||
const out = {
|
||||
fetchedAt: today,
|
||||
channel: { id: c.id, handle: c.snippet.customUrl, url: `https://www.youtube.com/${c.snippet.customUrl ?? 'channel/' + c.id}`, title: c.snippet.title, subscribers: fmtKo(Number(c.statistics.subscriberCount)), videos: Number(c.statistics.videoCount), totalViews: Number(c.statistics.viewCount) },
|
||||
top: all.slice(0, 10),
|
||||
topLong: all.filter((v) => !v.isShort && v.duration >= MIN_LONG_SEC && info(v) && v.embeddable !== false).slice(0, 10).map(strip),
|
||||
topShorts: all.filter((v) => v.isShort && info(v)).slice(0, 10).map(strip),
|
||||
shortsInfo: all.filter((v) => v.isShort && info(v)).slice(0, 10).map((v) => ({ ...strip(v), answer: '' })),
|
||||
all: all.map(strip),
|
||||
shortsDetection: `youtube.com/shorts/{id} HEAD 200 (상위 ${cand.length}편 확인, 나머지는 60초 이하), ${today}`,
|
||||
note: 'topLong·topShorts 는 정보형(후기·전후·토크·이벤트 제목 제외). shortsInfo.answer 는 사람이 영상을 보고 채운다(자동 생성하지 않음).',
|
||||
};
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 1));
|
||||
console.log(`저장 ${OUT} · 전체 ${all.length} · 롱폼 정보형 ${out.topLong.length} · 쇼츠 정보형 ${out.topShorts.length}`);
|
||||
function fmtKo(n) { return n >= 10000 ? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}만` : n.toLocaleString('ko-KR'); }
|
||||
@ -89,7 +89,7 @@ for (const a of topAreas) add({
|
||||
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'],
|
||||
title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`,
|
||||
intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`,
|
||||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep,
|
||||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, area: a,
|
||||
});
|
||||
if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({
|
||||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||||
@ -98,10 +98,28 @@ if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >=
|
||||
pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep,
|
||||
});
|
||||
|
||||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, ...c }) => ({
|
||||
// 글 ↔ 원장 영상 매칭: 사이트 데이터 videos.json(collect_youtube.mjs 산출)의 정보형 롱폼 중 제목이 글 주제 키워드와 겹치는 것 최대 3편.
|
||||
// 영상은 "이어서 볼 영상"과 카드 썸네일로만 쓴다. 자막이 없으므로 발언은 인용하지 않는다(§4-2).
|
||||
const videosPath = join(SITE, 'src/data/videos.json');
|
||||
const vdata = existsSync(videosPath) ? JSON.parse(readFileSync(videosPath, 'utf8')) : null;
|
||||
// 후보 = 정보형(info) 롱폼(쇼츠 아님, 90초 이상, 제목에 #shorts 없음)만. collect_youtube 가 붙인 info 판정을 그대로 쓴다.
|
||||
const longs = (vdata?.topLong ?? []).concat((vdata?.all ?? []).filter((v) => !v.isShort && v.duration >= 90 && v.info !== false && !/#\s?shorts/i.test(v.title))).filter((v, i, a) => a.findIndex((x) => x.id === v.id) === i);
|
||||
// 병원 소개·방문 안내는 영상을 붙이지 않는다(주제와 맞는 영상이 없고 아무 영상이나 붙으면 오해를 준다)
|
||||
const KW = {
|
||||
'what-is-clinic': [], 'visit-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진'],
|
||||
'precautions-guide': ['주의사항', '수술 후', '관리', '실밥', '부기', '붓기'], 'aftercare-program': ['관리', '애프터', '회복', '수술 후'], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
|
||||
};
|
||||
const areaKw = (a) => { const t = areaLabel(a); return [t, ...t.split(/[·,/\s]+/).filter((w) => w.length >= 2), a.area]; };
|
||||
function matchVideos(c) {
|
||||
if (!longs.length) return [];
|
||||
const kws = (KW[c.id] ?? []).concat(c.area ? areaKw(c.area) : []).filter(Boolean);
|
||||
if (!kws.length) return [];
|
||||
return longs.filter((v) => kws.some((k) => v.title.includes(k))).slice(0, 3).map((v) => ({ id: v.id, title: v.title, published: v.published }));
|
||||
}
|
||||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => ({
|
||||
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
|
||||
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
|
||||
videos: [], gallery: [],
|
||||
videos: matchVideos({ ...c, area }), gallery: [],
|
||||
}));
|
||||
|
||||
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
|
||||
|
||||
@ -4,11 +4,13 @@ interface Props { post: any }
|
||||
const { post } = Astro.props;
|
||||
const d = post.data;
|
||||
const v0 = d.videos?.[0] ?? d.video;
|
||||
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? '/favicon.svg');
|
||||
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? `${fact.shortName} 건물 외관`);
|
||||
// 썸네일 우선순위: 지정 썸네일 → 히어로 → 첫 영상 → 건물 사진 → 병원 로고. 전부 없으면 이미지 없이 낸다(프레임워크 기본 파비콘을 쓰지 않는다).
|
||||
const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? S.logo?.src ?? null);
|
||||
const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? S.logo?.alt ?? `${fact.shortName} 로고`);
|
||||
const isLogo = !d.thumbnail && !d.hero?.src && !v0 && !S.buildingImage?.src && Boolean(S.logo?.src);
|
||||
---
|
||||
<article class="card has-thumb">
|
||||
<a class="thumb" href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>
|
||||
<article class={`card ${thumb ? 'has-thumb' : ''}`}>
|
||||
{thumb && <a class={`thumb ${isLogo ? 'thumb-logo' : ''}`} href={`/posts/${post.id}`}><img src={thumb} alt={thumbAlt} loading="lazy" /></a>}
|
||||
<div class="body">
|
||||
<div class="cat">{d.category} · {CATEGORY_LABELS[d.category]}</div>
|
||||
<h3><a href={`/posts/${post.id}`}>{d.title}</a></h3>
|
||||
|
||||
@ -239,6 +239,8 @@ td { color: var(--slate-700); }
|
||||
.figure.portrait img { aspect-ratio: auto; }
|
||||
.card.has-thumb { padding: 0; overflow: hidden; }
|
||||
.card.has-thumb .thumb img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
|
||||
.card.has-thumb .thumb.thumb-logo { display: block; background: #F7F8FC; aspect-ratio: 16 / 9; }
|
||||
.card.has-thumb .thumb.thumb-logo img { object-fit: contain; padding: 18% 22%; box-sizing: border-box; }
|
||||
.card.has-thumb .body { padding: 1.2rem 1.4rem 1.4rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||
.card.has-thumb .body p { flex: 1; }
|
||||
.doctor-strip { display: flex; gap: 1rem; align-items: center; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; margin: 1.4rem 0; box-shadow: var(--shadow); }
|
||||
|
||||
@ -13,8 +13,10 @@ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url)));
|
||||
const yaml = createRequire(join(ROOT, 'supporters', 'package.json'))('js-yaml'); // supporters 의존성 재사용 (js-yaml v4 load 는 safe)
|
||||
const SUP = join(ROOT, 'supporters');
|
||||
const TEMPLATE = join(ROOT, 'templates', 'supporters-astro');
|
||||
const args = process.argv.slice(2);
|
||||
@ -24,7 +26,7 @@ const clinic = opt('clinic'); const url = opt('url');
|
||||
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
|
||||
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const PHASES = ['evidence', 'data', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
|
||||
const PHASES = ['evidence', 'data', 'youtube', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
|
||||
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, ''); } })();
|
||||
@ -86,8 +88,14 @@ await phase('evidence', () => {
|
||||
// 2. 템플릿 복제 + 팩트 시트·의료진 초안
|
||||
await phase('data', () => {
|
||||
if (!existsSync(TEMPLATE)) throw new Error(`템플릿 없음: ${TEMPLATE} (supporters 에서 npm run export:template)`);
|
||||
// 재실행이면 이미 만든 글과 유튜브 집계는 보존한다(생성 비용·API 절약). --regenerate 면 글도 버린다.
|
||||
const KEEP = join(WORK, 'keep'); rmSync(KEEP, { recursive: true, force: true }); mkdirSync(KEEP, { recursive: true });
|
||||
if (existsSync(POSTS) && !flag('regenerate')) cpSync(POSTS, join(KEEP, 'posts'), { recursive: true });
|
||||
if (existsSync(join(DATA, 'videos.json'))) cpSync(join(DATA, 'videos.json'), join(KEEP, 'videos.json'));
|
||||
rmSync(SITE, { recursive: true, force: true });
|
||||
cpSync(TEMPLATE, SITE, { recursive: true, filter: (s) => !/\/(node_modules|dist|\.astro)(\/|$)/.test(s) });
|
||||
if (existsSync(join(KEEP, 'posts'))) cpSync(join(KEEP, 'posts'), POSTS, { recursive: true });
|
||||
if (existsSync(join(KEEP, 'videos.json'))) cpSync(join(KEEP, 'videos.json'), join(DATA, 'videos.json'));
|
||||
for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); }
|
||||
cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt'));
|
||||
|
||||
@ -113,6 +121,60 @@ await phase('data', () => {
|
||||
return `${fact.shortName || '(상호 확인 대기)'} · 전화 ${fact.phone || '확인 대기'} · 의료진 ${doctors.length}명`;
|
||||
});
|
||||
|
||||
// 2b. 로고·파비콘 (--logo <svg|png 파일>) + 유튜브 채널 집계 (--youtube <채널> 또는 근거의 유튜브 링크 자동 발견)
|
||||
await phase('youtube', () => {
|
||||
const notes = [];
|
||||
// 로고: 헤더·푸터·카드 대체 이미지·파비콘. SVG 는 viewBox 를 정사각 파비콘으로 감싼다.
|
||||
const logoPath = opt('logo') ? resolve(opt('logo')) : null;
|
||||
if (logoPath && existsSync(logoPath)) {
|
||||
const ext = logoPath.toLowerCase().endsWith('.svg') ? 'svg' : logoPath.toLowerCase().endsWith('.png') ? 'png' : 'jpg';
|
||||
mkdirSync(join(SITE, 'public/img'), { recursive: true });
|
||||
cpSync(logoPath, join(SITE, 'public/img', `logo.${ext}`));
|
||||
const site = readJson(join(DATA, 'site.json'));
|
||||
let width = 150, height = 40;
|
||||
if (ext === 'svg') {
|
||||
const svg = readFileSync(logoPath, 'utf8');
|
||||
const vb = svg.match(/viewBox="([\d.\s-]+)"/)?.[1]?.split(/\s+/).map(Number);
|
||||
if (vb && vb[2] && vb[3]) { height = 40; width = Math.round((vb[2] / vb[3]) * 40); }
|
||||
// 파비콘: 흰 정사각 배경 + 로고 중앙 (가로 로고면 폭 기준 88%)
|
||||
const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||
const [vx, vy, vw, vh] = vb ?? [0, 0, 100, 100];
|
||||
const scale = Math.min(56 / vw, 56 / vh); const w = vw * scale, h = vh * scale;
|
||||
const fav = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#ffffff"/><svg x="${(64 - w) / 2}" y="${(64 - h) / 2}" width="${w}" height="${h}" viewBox="${vx} ${vy} ${vw} ${vh}">${inner}</svg></svg>`;
|
||||
writeFileSync(join(SITE, 'public/favicon.svg'), fav);
|
||||
} else {
|
||||
cpSync(logoPath, join(SITE, 'public', `favicon.${ext}`));
|
||||
}
|
||||
site.logo = { src: `/img/logo.${ext}`, alt: `${readJson(join(DATA, 'factSheet.json')).shortName || ''} 로고`.trim(), width, height };
|
||||
site.logoFoot = site.logo;
|
||||
writeJson(join(DATA, 'site.json'), site);
|
||||
notes.push(`로고 ${ext} · 파비콘`);
|
||||
} else if (opt('logo')) notes.push(`로고 파일 없음: ${opt('logo')}`);
|
||||
|
||||
// 유튜브: 채널 인자 없으면 근거 페이지 링크에서 youtube.com/channel|@handle 을 찾는다
|
||||
let channel = opt('youtube') ?? null;
|
||||
if (!channel) {
|
||||
const idx = readJson(join(EV, 'index.json'));
|
||||
outer: for (const p of idx.pages.slice(0, 30)) {
|
||||
const pg = readJson(join(EV, p.file));
|
||||
for (const l of pg.links ?? []) { const u = String(l.href ?? l.url ?? l ?? ''); const m = u.match(/youtube\.com\/(channel\/UC[\w-]+|@[\w.-]+)/); if (m) { channel = m[0]; break outer; } }
|
||||
}
|
||||
}
|
||||
if (!channel) return (notes.concat('유튜브 채널 없음 (--youtube 로 지정 가능)')).join(' · ');
|
||||
const prev = existsSync(join(DATA, 'videos.json')) ? readJson(join(DATA, 'videos.json')) : null;
|
||||
if (prev?.fetchedAt === today && prev.channel?.id) notes.push('오늘 집계본 재사용');
|
||||
else { const r = run('node', ['scripts/collect_youtube.mjs', '--channel', channel, '--out', join(DATA, 'videos.json')]); if (!r.ok) throw new Error(`유튜브 집계 실패\n${tail(r.out)}`); }
|
||||
const v = readJson(join(DATA, 'videos.json'));
|
||||
// 홈 "Doctors Explain" 3편 + 팩트 시트 surfaces.youtube
|
||||
const site = readJson(join(DATA, 'site.json'));
|
||||
site.homeVideos = (v.topLong ?? []).slice(0, 3).map((x) => ({ id: x.id, title: x.title, published: x.published }));
|
||||
writeJson(join(DATA, 'site.json'), site);
|
||||
const fact = readJson(join(DATA, 'factSheet.json'));
|
||||
fact.surfaces = { ...(fact.surfaces ?? {}), youtube: { label: '유튜브', url: v.channel.url, title: v.channel.title, subscribers: v.channel.subscribers, videos: v.channel.videos, checked: v.fetchedAt } };
|
||||
writeJson(join(DATA, 'factSheet.json'), fact);
|
||||
return notes.concat(`${v.channel.title} · 영상 ${v.channel.videos} · 설명형 롱폼 ${v.topLong.length} · 쇼츠 ${v.topShorts.length}`).join(' · ');
|
||||
});
|
||||
|
||||
// 3. 병원 입력 반영 (있으면)
|
||||
await phase('inputs', () => {
|
||||
if (!opt('inputs') && !flag('from-supabase')) return '입력 없음 (확인 대기로 발행)';
|
||||
@ -136,6 +198,26 @@ await phase('briefs', () => {
|
||||
await phase('generate', () => {
|
||||
if (flag('skip-generate')) return '건너뜀 (--skip-generate)';
|
||||
const GEN = join(WORK, 'gen');
|
||||
// 이미 생성된 글이 있고 --regenerate 가 없으면 다시 만들지 않고, brief 의 영상만 frontmatter 에 붙인다(비용 0).
|
||||
const existing = existsSync(POSTS) ? readdirSync(POSTS).filter((f) => f.endsWith('.md')) : [];
|
||||
if (existing.length && !flag('regenerate')) {
|
||||
const briefs = readJson(join(WORK, 'briefs.json')).posts ?? [];
|
||||
let attached = 0;
|
||||
for (const b of briefs) {
|
||||
const p = join(POSTS, `${b.id}.md`);
|
||||
if (!existsSync(p)) continue;
|
||||
const src = readFileSync(p, 'utf8');
|
||||
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) continue;
|
||||
const data = yaml.load(m[1]) ?? {};
|
||||
const next = b.videos ?? [];
|
||||
if (JSON.stringify(data.videos ?? []) === JSON.stringify(next)) continue; // brief 가 영상의 단일 원본. 같으면 건너뛴다
|
||||
data.videos = next;
|
||||
writeFileSync(p, `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${m[2].trim()}\n`);
|
||||
attached++;
|
||||
}
|
||||
status.posts = (status.posts ?? []).map((x) => ({ ...x, videos: (briefs.find((b) => b.id === x.id)?.videos ?? []).length }));
|
||||
return `기존 글 ${existing.length}편 유지 (--regenerate 없음) · 영상 붙임 ${attached}편`;
|
||||
}
|
||||
const argv = ['scripts/generate_posts.mjs', '--clinic', clinic, '--site', SITE, '--evidence', EV, '--brief', join(WORK, 'briefs.json'), '--out', GEN, '--today', today];
|
||||
argv.push('--max-cost-usd', opt('max-cost-usd', '10')); // v2 §9-5 haewon 결정: 병원당 한 번 실행 $10
|
||||
const r = run('node', argv);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user