o2o-infinith-demo/templates/supporters-astro/scripts/collect_youtube.mjs

101 lines
7.9 KiB
JavaScript

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