diff --git a/docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md b/docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md index 20925b1..4e5c4da 100644 --- a/docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md +++ b/docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md @@ -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 `: 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 ` → 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) diff --git a/supporters/briefs/wonjin/logo-horizontal.svg b/supporters/briefs/wonjin/logo-horizontal.svg new file mode 100644 index 0000000..defb7c5 --- /dev/null +++ b/supporters/briefs/wonjin/logo-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/supporters/scripts/collect_youtube.mjs b/supporters/scripts/collect_youtube.mjs new file mode 100644 index 0000000..78b8b0c --- /dev/null +++ b/supporters/scripts/collect_youtube.mjs @@ -0,0 +1,100 @@ +// 유튜브 채널 집계 (v1 §4 "유튜브 통계·쇼츠 판별" 이식). YouTube Data API v3 만 쓴다(자막 없음). +// node scripts/collect_youtube.mjs --channel [--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'); } diff --git a/supporters/scripts/default_briefs.mjs b/supporters/scripts/default_briefs.mjs index ab0a823..8c3d2c9 100644 --- a/supporters/scripts/default_briefs.mjs +++ b/supporters/scripts/default_briefs.mjs @@ -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 }; diff --git a/supporters/src/components/PostCard.astro b/supporters/src/components/PostCard.astro index 812cb7d..b3915c2 100644 --- a/supporters/src/components/PostCard.astro +++ b/supporters/src/components/PostCard.astro @@ -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); --- -
- {thumbAlt} +
+ {thumb && {thumbAlt}}
{d.category} · {CATEGORY_LABELS[d.category]}

{d.title}

diff --git a/supporters/src/styles/global.css b/supporters/src/styles/global.css index f04ca68..70687fc 100644 --- a/supporters/src/styles/global.css +++ b/supporters/src/styles/global.css @@ -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); } diff --git a/templates/supporters-astro/briefs/wonjin/logo-horizontal.svg b/templates/supporters-astro/briefs/wonjin/logo-horizontal.svg new file mode 100644 index 0000000..defb7c5 --- /dev/null +++ b/templates/supporters-astro/briefs/wonjin/logo-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/supporters-astro/scripts/collect_youtube.mjs b/templates/supporters-astro/scripts/collect_youtube.mjs new file mode 100644 index 0000000..78b8b0c --- /dev/null +++ b/templates/supporters-astro/scripts/collect_youtube.mjs @@ -0,0 +1,100 @@ +// 유튜브 채널 집계 (v1 §4 "유튜브 통계·쇼츠 판별" 이식). YouTube Data API v3 만 쓴다(자막 없음). +// node scripts/collect_youtube.mjs --channel [--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'); } diff --git a/templates/supporters-astro/scripts/default_briefs.mjs b/templates/supporters-astro/scripts/default_briefs.mjs index ab0a823..8c3d2c9 100644 --- a/templates/supporters-astro/scripts/default_briefs.mjs +++ b/templates/supporters-astro/scripts/default_briefs.mjs @@ -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 }; diff --git a/templates/supporters-astro/src/components/PostCard.astro b/templates/supporters-astro/src/components/PostCard.astro index 812cb7d..b3915c2 100644 --- a/templates/supporters-astro/src/components/PostCard.astro +++ b/templates/supporters-astro/src/components/PostCard.astro @@ -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); --- -
- {thumbAlt} +
+ {thumb && {thumbAlt}}
{d.category} · {CATEGORY_LABELS[d.category]}

{d.title}

diff --git a/templates/supporters-astro/src/styles/global.css b/templates/supporters-astro/src/styles/global.css index f04ca68..70687fc 100644 --- a/templates/supporters-astro/src/styles/global.css +++ b/templates/supporters-astro/src/styles/global.css @@ -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); } diff --git a/workers/supporters-build/run.mjs b/workers/supporters-build/run.mjs index ddace4d..b8191b4 100644 --- a/workers/supporters-build/run.mjs +++ b/workers/supporters-build/run.mjs @@ -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 --url '); 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 ) + 유튜브 채널 집계 (--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]*?]*>/, '').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 = `${inner}`; + 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);