// 유튜브 채널 집계 (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'); }