feat: Add Youtube video summarizer
This commit is contained in:
+365
@@ -0,0 +1,365 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>YouTube Video Summarizer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #fff;
|
||||||
|
--bg-alt: #f5f5f5;
|
||||||
|
--text: #000;
|
||||||
|
--text-muted: #666;
|
||||||
|
--border: #ddd;
|
||||||
|
--accent: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a1a;
|
||||||
|
--bg-alt: #2a2a2a;
|
||||||
|
--text: #fff;
|
||||||
|
--text-muted: #aaa;
|
||||||
|
--border: #444;
|
||||||
|
--accent: #5a9fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 3px solid var(--bg-alt);
|
||||||
|
border-top: 3px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary :is(ul, ol) {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary :is(ul, ol) :is(ul, ol) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary blockquote {
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#summary h2,
|
||||||
|
#summary h3 {
|
||||||
|
margin: 1.5rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #c33;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="videoUrl" placeholder="https://www.youtube.com/watch?v=..." />
|
||||||
|
<textarea id="video" placeholder="Video transcript"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="summarizeBtn" style="display: none"></button>
|
||||||
|
|
||||||
|
<div id="loading" class="loading" style="display: none">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Generating summary...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
|
||||||
|
<div id="summary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const videoUrlInput = document.getElementById("videoUrl");
|
||||||
|
const textInput = document.getElementById("text");
|
||||||
|
const summarizeBtn = document.getElementById("summarizeBtn");
|
||||||
|
const loadingDiv = document.getElementById("loading");
|
||||||
|
const errorDiv = document.getElementById("error");
|
||||||
|
const summaryDiv = document.getElementById("summary");
|
||||||
|
|
||||||
|
// Pre-fill with YouTube URL if available and auto-submit
|
||||||
|
if (window.env?.YOUTUBE_URL || window.env?.YOUTUBE_TRANSCRIPT) {
|
||||||
|
if (window.env?.YOUTUBE_URL) videoUrlInput.value = window.env.YOUTUBE_URL;
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", () => summarizeBtn.click());
|
||||||
|
} else {
|
||||||
|
summarizeBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeBtn.addEventListener("click", async () => {
|
||||||
|
const url = videoUrlInput.value.trim();
|
||||||
|
const transcript = window.env?.YOUTUBE_TRANSCRIPT;
|
||||||
|
|
||||||
|
if (!transcript && !url) {
|
||||||
|
showError("Please enter a YouTube URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcript) {
|
||||||
|
// Validate YouTube URL
|
||||||
|
const youtubeUrlPattern = /^https?:\/\/(www\.)?youtube\.com\/watch\?v=.+$|^https?:\/\/youtu\.be\/.+$/;
|
||||||
|
if (!youtubeUrlPattern.test(url)) {
|
||||||
|
showError("Please enter a valid YouTube URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await summarize(url, transcript);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
errorDiv.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
errorDiv.style.display = "none";
|
||||||
|
errorDiv.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summarize(url, transcript) {
|
||||||
|
hideError();
|
||||||
|
summaryDiv.innerHTML = "";
|
||||||
|
loadingDiv.style.display = "block";
|
||||||
|
summarizeBtn.disabled = true;
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
Give me keypoints of this video transcript, with enough context, also with quotes from the content.
|
||||||
|
Only the summary please.
|
||||||
|
Skip promotions.
|
||||||
|
Do not censor profanity.
|
||||||
|
Formatted in valid markdown without any wrappers or code blocks.
|
||||||
|
`;
|
||||||
|
|
||||||
|
let fullResponse = "";
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chatWithGemini({
|
||||||
|
prompt: prompt,
|
||||||
|
messages: [{ role: "user", content: transcript ?? url }],
|
||||||
|
model: "gemini-3-flash-preview",
|
||||||
|
onContent: (text) => {
|
||||||
|
fullResponse += text;
|
||||||
|
summaryDiv.innerHTML = markdownToHtml(fullResponse);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showError(`Error: ${err.message}`);
|
||||||
|
loadingDiv.style.display = "none";
|
||||||
|
summarizeBtn.disabled = false;
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingDiv.style.display = "none";
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Error: ${err.message}`);
|
||||||
|
loadingDiv.style.display = "none";
|
||||||
|
} finally {
|
||||||
|
summarizeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = markdownit({ html: false });
|
||||||
|
function markdownToHtml(markdown) {
|
||||||
|
return md.render(markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatWithGemini({ prompt, onContent, onError, messages, signal, apiKey = window.env.GEMINI_API_KEY, model }) {
|
||||||
|
const contents = messages.map((msg, i, arr) => {
|
||||||
|
const role = msg.role === "assistant" ? "model" : "user";
|
||||||
|
let parts = [{ text: msg.content }];
|
||||||
|
|
||||||
|
// Add YouTube URLs as file data for the final user message
|
||||||
|
const isLastMessage = i === arr.length - 1;
|
||||||
|
if (isLastMessage && role === "user") {
|
||||||
|
const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g;
|
||||||
|
const youtubeUrls = msg.content.match(youtubeUrlPattern) || [];
|
||||||
|
const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } }));
|
||||||
|
parts = [...parts, ...fileParts];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
parts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(prompt ? { system_instruction: { parts: [{ text: prompt }] } } : {}),
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
thinkingConfig: { thinkingLevel: "low" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
await onError?.(new Error(`Gemini API Error ${response.status}: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buffer.length) {
|
||||||
|
// Find start of JSON object
|
||||||
|
const start = buffer.indexOf("{", pos);
|
||||||
|
if (start === -1) break;
|
||||||
|
|
||||||
|
// Find matching closing brace
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
let end = -1;
|
||||||
|
|
||||||
|
for (let i = start; i < buffer.length; i++) {
|
||||||
|
const ch = buffer[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString) {
|
||||||
|
if (ch === "{") depth++;
|
||||||
|
else if (ch === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) {
|
||||||
|
// Incomplete JSON, keep it in buffer
|
||||||
|
buffer = buffer.slice(start);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and process JSON
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(buffer.slice(start, end + 1));
|
||||||
|
const parts = json.candidates?.[0]?.content?.parts || [];
|
||||||
|
const text = parts
|
||||||
|
.filter((p) => p.text)
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (text) await onContent(text);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only unparsed remainder
|
||||||
|
buffer = buffer.slice(pos);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== "AbortError") await onError?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user