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