그럼 위 내용도 모두 수집할때 하면 좋지 않을까? 소스 업데이트 해주고, 구글 시트에 누적해서 업데이트 해줄수 있을까? 수집버튼 -> API로 전송 -> 구글시트에 정보 누적




좋아요! 버튼 클릭 → API 전송 → 구글시트 누적 기록까지 한 번에 이어지도록 확장 + 서버 둘 다 업데이트 버전으로 잡아드릴게요.

핵심은 ①확장자가 “요약 메타데이터”를 더 풍부하게 보내고, ②서버가 그 요약만 골라 구글시트에 Append 해주는 흐름입니다. (대본은 제외)


1) 확장자 업데이트 — 수집 필드 확대 + (선택) YouTube Data API 연동

추가 수집 필드

  • thumbnail_url, tags[], category

  • like_count, comment_count (옵션: YouTube Data API 키 설정 시 정확값 수집 / 미설정 시 DOM fallback 또는 null)

  • 그 외 기존 필드 유지: title, view_count, duration_seconds, channel_name, upload_date, language, is_auto_generated, url, video_id, source, extracted_at, extension_version

A) popup.html / popup.js — API 엔드포인트 + (선택) YouTube API 키 입력

popup.html에 입력란 1개 추가:

html
<!-- 기존 내용 아래쪽에 추가 --> <div class="row"> <label for="ytkey">YouTube Data API Key (선택)</label> <input id="ytkey" type="password" placeholder="AIza... (선택)" /> </div>

popup.js 저장/로드 추가:

js
const DEFAULT_ENDPOINT = "http://localhost:8000/captions/process"; document.addEventListener("DOMContentLoaded", async () => { const { apiEndpoint, ytApiKey } = await chrome.storage.sync.get({ apiEndpoint: DEFAULT_ENDPOINT, ytApiKey: "" }); document.getElementById("api").value = apiEndpoint || DEFAULT_ENDPOINT; document.getElementById("ytkey").value = ytApiKey || ""; document.getElementById("ver").textContent = chrome.runtime.getManifest().version; }); document.getElementById("save").addEventListener("click", async () => { const api = document.getElementById("api").value.trim(); const ytApiKey = document.getElementById("ytkey").value.trim(); await chrome.storage.sync.set({ apiEndpoint: api || DEFAULT_ENDPOINT, ytApiKey }); const msg = document.getElementById("msg"); msg.textContent = "저장되었습니다."; msg.className = "ok"; setTimeout(() => { msg.textContent = ""; msg.className = "muted"; }, 1500); });

B) background.js — (선택) YouTube Data API 조회 지원

js
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "POST_CAPTIONS") { (async () => { try { const { endpoint, payload } = msg; const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); sendResponse({ ok: res.ok, status: res.status, body: await res.text() }); } catch (e) { sendResponse({ ok: false, status: 0, body: String(e) }); } })(); return true; } // 선택: YouTube Data API 조회 if (msg?.type === "YT_VIDEOS_LIST") { (async () => { try { const { videoId } = msg; const { ytApiKey } = await chrome.storage.sync.get({ ytApiKey: "" }); if (!ytApiKey) return sendResponse({ ok: false, status: 0, body: "NO_API_KEY" }); const url = new URL("https://www.googleapis.com/youtube/v3/videos"); url.searchParams.set("id", videoId); url.searchParams.set("part", "snippet,statistics"); url.searchParams.set("key", ytApiKey); const r = await fetch(url.toString()); sendResponse({ ok: r.ok, status: r.status, body: await r.json() }); } catch (e) { sendResponse({ ok: false, status: 0, body: String(e) }); } })(); return true; } });

C) captions_extractor.js — 확장 필드 수집(썸네일/태그/카테고리)

js
function getExtraFromPlayer(pr) { const vd = pr?.videoDetails || {}; const mf = pr?.microformat?.playerMicroformatRenderer || {}; const thumbnails = vd?.thumbnail?.thumbnails || []; const thumbnail_url = thumbnails.length ? thumbnails[thumbnails.length - 1].url : ""; const tags = vd?.keywords || []; const category = mf?.category || ""; // 있으면 채움 return { thumbnail_url, tags, category }; }

D) content_script.js — API 키 있으면 정확 통계 보강 / 없으면 폴백

js
async function fetchStatsViaAPI(videoId) { const res = await chrome.runtime.sendMessage({ type: "YT_VIDEOS_LIST", videoId }); if (!res?.ok) return null; const data = res.body; const item = data?.items?.[0]; if (!item) return null; const stats = item.statistics || {}; const snip = item.snippet || {}; return { like_count: stats.likeCount ? Number(stats.likeCount) : null, comment_count: stats.commentCount ? Number(stats.commentCount) : null, category: snip.categoryId || null, // 카테고리명은 별도 매핑 필요, 여기서는 ID 저장 tags: snip.tags || null, publish_date: snip.publishedAt || null }; } // DOM 폴백(정확도↓, API키 없을 때 사용) function roughDomStatsFallback() { try { // 좋아요 버튼 라벨에서 숫자 추출 (지역/언어에 따라 불안정) const likeBtn = document.querySelector('ytd-toggle-button-renderer[is-icon-button][aria-pressed]'); let like_count = null; if (likeBtn?.ariaLabel) { const m = likeBtn.ariaLabel.replaceAll(',', '').match(/(\d+)/); like_count = m ? Number(m[1]) : null; } // 댓글 카운트는 DOM에 보이지 않는 경우 많음 → null return { like_count, comment_count: null }; } catch { return { like_count: null, comment_count: null }; } } async function onClickExtractAndSend() { const now = Date.now(); if (now - lastPostAt < 3000) { showToast("잠시만요… 처리 중입니다", false); return; } try { const pr = await window.__YCX__.getPlayerResponse(); const base = await window.__YCX__.extractAll(); const { thumbnail_url, tags: tagsFromPR, category: catFromPR } = getExtraFromPlayer(pr); const url = window.__YCX__.getWatchUrl(); const video_id = window.__YCX__.getVideoIdFromUrl(url) || pr?.videoDetails?.videoId || ""; // 기본 페이로드 const payload = { video_id, url, title: base.title || "", description: base.description || "", channel_name: base.channel_name || "", upload_date: base.upload_date || "", duration_seconds: base.duration_seconds || 0, view_count: base.view_count || 0, captions: base.captions || [], language: base.language || "", is_auto_generated: !!base.is_auto_generated, source: "external", extracted_at: new Date().toISOString(), extension_version: EXT_VERSION, // 확장 필드 thumbnail_url, tags: tagsFromPR || [], category: catFromPR || "" }; if (!payload.video_id) return showToast("video_id 식별 실패", false); if (!payload.captions?.length) return showToast("이 영상은 자막이 없습니다", false); // 보강: YouTube Data API(선택) const stats = await fetchStatsViaAPI(payload.video_id); if (stats) { payload.like_count = stats.like_count ?? null; payload.comment_count = stats.comment_count ?? null; payload.category = payload.category || stats.category || ""; if (stats.tags) payload.tags = payload.tags?.length ? payload.tags : stats.tags; if (stats.publish_date && !payload.upload_date) payload.upload_date = stats.publish_date.slice(0,10); } else { // DOM 폴백 const rough = roughDomStatsFallback(); payload.like_count = rough.like_count; payload.comment_count = rough.comment_count; } const { apiEndpoint } = await chrome.storage.sync.get({ apiEndpoint: DEFAULT_ENDPOINT }); lastPostAt = now; const res = await chrome.runtime.sendMessage({ type: "POST_CAPTIONS", endpoint: apiEndpoint, payload }); if (res?.ok) showToast(`전송 성공: ${payload.video_id}`, true); else { const bodyPreview = (res?.body || "").slice(0, 120); showToast(`전송 실패(${res?.status}): ${bodyPreview}`, false); } } catch (e) { showToast(`오류: ${e.message.slice(0,120)}`, false); console.error(e); } }

참고: dislikeCount는 API에서도 제공되지 않습니다.(2021년 이후 비공개) → dislike_count는 항상 null로 두는 걸 권장합니다.


2) 서버(api_server.py) 업데이트 — 구글시트 누적 기록(대본 제외)

구글 시트에 요약 행만 Append 합니다.

환경 변수:

  • SHEETS_CREDENTIALS_JSON : 서비스 계정 JSON(내용 문자열) 또는 JSON 파일 경로

  • SHEET_ID : 대상 구글 시트 ID

  • SHEET_NAME : 시트 탭 이름 (예: Logs)

python
# api_server.py (핵심 추가 부분) import os, json, datetime from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from google.oauth2.service_account import Credentials from googleapiclient.discovery import build app = FastAPI() def _get_sheets_service(): creds_json = os.getenv("SHEETS_CREDENTIALS_JSON") if not creds_json: return None # 문자열로 들어오면 파싱, 파일 경로면 파일 읽기 if creds_json.strip().startswith("{"): info = json.loads(creds_json) creds = Credentials.from_service_account_info(info, scopes=["https://www.googleapis.com/auth/spreadsheets"]) else: creds = Credentials.from_service_account_file(creds_json, scopes=["https://www.googleapis.com/auth/spreadsheets"]) return build("sheets", "v4", credentials=creds, cache_discovery=False) SHEET_ID = os.getenv("SHEET_ID") SHEET_NAME = os.getenv("SHEET_NAME", "Logs") def append_to_sheet(summary: dict): if not SHEET_ID: return # 시트 미설정 시 건너뜀 svc = _get_sheets_service() if not svc: return values = [[ summary.get("extracted_at"), # 수집일자(ISO) summary.get("video_id"), summary.get("title"), summary.get("url"), summary.get("channel_name"), summary.get("upload_date"), summary.get("duration_seconds"), summary.get("view_count"), summary.get("like_count"), summary.get("comment_count"), summary.get("category"), ", ".join(summary.get("tags") or []), summary.get("language"), summary.get("is_auto_generated"), summary.get("thumbnail_url"), summary.get("source"), summary.get("extension_version") ]] body = {"values": values} svc.spreadsheets().values().append( spreadsheetId=SHEET_ID, range=f"{SHEET_NAME}!A:Z", valueInputOption="RAW", insertDataOption="INSERT_ROWS", body=body ).execute() def build_summary(payload: dict): # captions(대본)은 제외 return { "extracted_at": payload.get("extracted_at"), "video_id": payload.get("video_id"), "title": payload.get("title"), "url": payload.get("url"), "channel_name": payload.get("channel_name"), "upload_date": payload.get("upload_date"), "duration_seconds": payload.get("duration_seconds"), "view_count": payload.get("view_count"), "like_count": payload.get("like_count"), "comment_count": payload.get("comment_count"), "category": payload.get("category"), "tags": payload.get("tags"), "language": payload.get("language"), "is_auto_generated": payload.get("is_auto_generated"), "thumbnail_url": payload.get("thumbnail_url"), "source": payload.get("source"), "extension_version": payload.get("extension_version"), } @app.post("/captions/process") async def process_captions(req: Request): data = await req.json() # 기존 처리(파일 저장/오케스트레이터 연동 등)는 그대로… # ---- 요약을 시트에 적재 ---- try: summary = build_summary(data) append_to_sheet(summary) except Exception as e: # 시트 적재 실패는 처리 흐름을 막지 않음 print("Sheet append error:", e) return JSONResponse({"status": "ok", "video_id": data.get("video_id")})

시트 컬럼 예시(첫 행 헤더 추천)

mathematica
A 수집일자(extracted_at) B video_id C title D url E channel_name F upload_date G duration_seconds H view_count I like_count J comment_count K category L tags M language N is_auto_generated O thumbnail_url P source Q extension_version

중복 방지(선택): video_id 기준으로 “이미 있으면 Append 대신 업데이트”를 원하시면, Apps Script Web App 방식이 더 깔끔합니다(서버에서 단일 POST → 스크립트가 시트에서 upsert). 원하시면 그 버전도 만들어 드릴게요.


3) 동작 흐름 요약

  1. 수집버튼 클릭(드래그로 원하는 위치 고정 가능)

  2. 확장자가 메타데이터+자막 수집 → (선택) YouTube Data API로 like_count / comment_count 보강

  3. 확장자가 API 서버POST /captions/process

  4. 서버는 자막 파일 저장/후처리와 별개로 요약 필드만 골라 구글시트에 Append

    • 대본(captions)은 시트에 넣지 않음

  5. 크롬 화면에 성공/실패 토스트가 버튼 옆에 표시


원하시면:

  • Apps Script upsert(덮어쓰기) 버전

  • 카테고리 ID → 카테고리명 매핑

  • 엔드포인트 인증 토큰(Authorization 헤더)

  • 구글시트에 주기적 리프레시(스케줄러)

까지 바로 추가해드릴게요. 어떤 방식으로 저장(append vs upsert) 원하시는지 말씀만 주세요!

댓글 남기기