UNPKG

summarizely-cli

Version:

YouTube summarizer that respects your existing subscriptions. No API keys required.

386 lines (384 loc) 15.6 kB
#!/usr/bin/env node "use strict"; /* Summarizely CLI — scaffold Usage: summarizely <youtube-url> [--model <name>] [--captions-only] [--json] */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("./utils"); const captions_1 = require("./captions"); const providers_1 = require("./providers"); const prompt_1 = require("./prompt"); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); // Minimal arg parser (no deps) function parseArgs(argv) { const args = {}; const rest = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--help" || a === "-h") args.help = true; else if (a === "--version" || a === "-v") args.version = true; else if (a === "--captions-only") args.captionsOnly = true; else if (a === "--json") args.json = true; else if (a === "--stream") args.stream = true; else if (a === "--batch") args.batch = true; else if (a === "--playlist") args.playlist = true; else if (a === "--channel") args.channel = true; else if (a === "--channel-limit") { if (i + 1 >= argv.length || String(argv[i + 1]).startsWith('-')) throw new Error("--channel-limit requires a value"); const v = argv[++i]; const n = Number(v); if (!Number.isFinite(n) || n <= 0) throw new Error("--channel-limit requires a positive number"); args.channelLimit = n; } else if (a === "--model") { if (i + 1 >= argv.length || String(argv[i + 1]).startsWith('-')) throw new Error("--model requires a value"); args.model = argv[++i]; } else if (a === "--provider") { if (i + 1 >= argv.length || String(argv[i + 1]).startsWith('-')) throw new Error("--provider requires a value"); args.provider = argv[++i]; } else if (a === "--output-dir") { if (i + 1 >= argv.length || String(argv[i + 1]).startsWith('-')) throw new Error("--output-dir requires a value"); args.outputDir = argv[++i]; } else if (a === "--no-save-transcript") { args.noSaveTranscript = true; } else if (a === "--max-chars") { if (i + 1 >= argv.length || String(argv[i + 1]).startsWith('-')) throw new Error("--max-chars requires a value"); const v = argv[++i]; const n = Number(v); if (!Number.isFinite(n) || n <= 0) throw new Error("--max-chars requires a positive number"); args.maxChars = n; } else if (a === "--no-cap") { args.noCap = true; } else { rest.push(a); } } // Support multiple URLs if (rest.length > 0) { args.urls = rest; } return args; } function printHelp() { const msg = ` Summarizely CLI Usage: summarizely <youtube-url> [options] summarizely <url1> <url2> ... [options] # Process multiple videos Options: -h, --help Show help -v, --version Show version --provider <name> Provider: claude-cli|codex-cli|ollama --model <name> Model preset (default: qwen2.5:0.5b-instruct for Ollama) --captions-only Force captions-only (no ASR; v1 doesn't do ASR) --output-dir <dir> Output directory (default: summaries) --no-save-transcript Do not write transcript .vtt/.txt files next to summary --max-chars <n> Max transcript chars for CLI providers (default ~80k) --no-cap Disable transcript cap for CLI providers --stream Stream output for supported providers (no JSON) --json Output JSON (metadata + content) --batch Process multiple URLs (auto-detected for multiple args) --playlist Extract and process all videos from a playlist --channel Extract recent videos from a channel --channel-limit <n> Number of channel videos to process (default: 10) `; process.stdout.write(msg); } function getVersion() { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../package.json"); return pkg.version || "0.0.0"; } catch { return "0.0.0"; } } function isYouTubeUrl(u) { if (!u) return false; try { const url = new URL(u); return /(^|\.)youtube\.com$/.test(url.hostname) || url.hostname === "youtu.be"; } catch { return false; } } async function main() { const argv = process.argv.slice(2); let args; try { args = parseArgs(argv); } catch (e) { printHelp(); process.stderr.write(`\nError: ${e?.message || 'invalid arguments'}\n`); process.exitCode = 2; return; } if (args.help) return printHelp(); if (args.version) return process.stdout.write(getVersion() + "\n"); if (!isYouTubeUrl(args.url)) { printHelp(); process.stderr.write("\nError: please provide a valid YouTube URL.\n"); process.exitCode = 2; return; } const url = args.url; const vid = (0, utils_1.youtubeIdFromUrl)(url); const outputDir = args.outputDir || 'summaries'; // Check if transcript already exists for this video in directory structure let caps = null; if (vid && fs_1.default.existsSync(outputDir)) { const dirs = fs_1.default.readdirSync(outputDir, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.includes('_')); // Look for existing transcript in any directory for (const dir of dirs) { const metaPath = path_1.default.join(outputDir, dir.name, 'metadata.json'); if (fs_1.default.existsSync(metaPath)) { try { const meta = JSON.parse(fs_1.default.readFileSync(metaPath, 'utf8')); if (meta.videoId === vid) { // Found a directory with this video ID const transcriptPath = path_1.default.join(outputDir, dir.name, 'transcript.txt'); if (fs_1.default.existsSync(transcriptPath)) { process.stderr.write(`> Found existing transcript for video ${vid} in ${dir.name}\n`); // Load plain transcript const transcript = fs_1.default.readFileSync(transcriptPath, 'utf8'); caps = { title: meta.title || 'YouTube Video', videoId: vid, url, transcript // vtt is optional, don't set it when loading from plain text }; process.stderr.write(`> Using existing transcript from: ${dir.name}/transcript.txt\n`); break; } } } catch (e) { // Continue checking other directories } } } } // Fetch captions if not found locally if (!caps) { process.stderr.write('> Fetching captions via yt-dlp...\n'); caps = (0, captions_1.fetchCaptions)(url); if (!caps) { const lines = []; lines.push('Captions not available.'); if (!(0, captions_1.hasYtDlp)()) { lines.push('Tip: Install yt-dlp for best results:'); lines.push(' ' + (0, captions_1.getYtDlpInstallHint)()); } process.stderr.write(lines.join('\n') + '\n'); process.exitCode = 4; return; } } // Provider routing const choice = (0, providers_1.selectProvider)(process.env); let markdown = null; if (args.provider) { const validProviders = ['claude-cli', 'codex-cli', 'ollama']; if (!validProviders.includes(args.provider)) { printHelp(); process.stderr.write(`\nError: unknown provider: ${args.provider}. Valid options: ${validProviders.join(', ')}\n`); process.exitCode = 2; return; } choice.provider = args.provider; } // Exit if no provider is available if (!choice.provider) { process.stderr.write('No provider available. Install a CLI provider (claude, codex) or Ollama.\n'); process.stderr.write(`Reason: ${choice.reason}\n`); process.exitCode = 5; return; } if (choice.provider) { const isCliProvider = choice.provider === 'claude-cli' || choice.provider === 'codex-cli'; const maxChars = isCliProvider && !args.noCap ? (args.maxChars ?? 80000) : undefined; const prompt = (0, prompt_1.buildPrompt)(caps, vid || caps.videoId, maxChars ? { maxChars } : undefined); if (args.stream) { if (args.json) { process.stderr.write("--stream and --json cannot be used together.\n"); process.exitCode = 2; return; } if (choice.provider === 'claude-cli' || choice.provider === 'codex-cli') { process.stderr.write('> Streaming not supported for CLI providers; running non-stream.\n'); try { markdown = await (0, providers_1.summarizeWithProvider)(choice.provider, caps, prompt, { model: args.model }); } catch (e) { if (e instanceof providers_1.ProviderError) { process.stderr.write((0, providers_1.formatProviderError)(choice.provider, e) + '\n'); } else { process.stderr.write(`Provider error: ${e?.message || e}\n`); } process.exitCode = 5; return; } } else { const { summarizeWithProviderStream } = await Promise.resolve().then(() => __importStar(require('./providers'))); const chunks = []; const out = await summarizeWithProviderStream(choice.provider, caps, prompt, { model: args.model, onChunk: (c) => { chunks.push(c); process.stdout.write(c); }, }); markdown = out ?? (chunks.length ? chunks.join('') : null); // ensure a newline after streaming process.stdout.write('\n'); } } else { try { markdown = await (0, providers_1.summarizeWithProvider)(choice.provider, caps, prompt, { model: args.model }); } catch (e) { // Print friendly provider error and exit if (e instanceof providers_1.ProviderError) { process.stderr.write((0, providers_1.formatProviderError)(choice.provider, e) + '\n'); } else { process.stderr.write(`Provider error: ${e?.message || e}\n`); } process.exitCode = 5; return; } } } if (!markdown) { process.stderr.write(`Provider ${choice.provider} returned no output\n`); process.exitCode = 5; return; } // Output writing — summaries/<ISO>_<Title>/ (0, utils_1.ensureDir)(outputDir); const titleRaw = caps.title || vid || 'video'; const titleUnderscore = titleRaw.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 120); const stamp = (0, utils_1.toIsoCompact)(new Date()); // YYYY-MM-DDTHH-mm-ssZ const videoDir = path_1.default.join(outputDir, `${stamp}_${titleUnderscore}`); (0, utils_1.ensureDir)(videoDir); const fpath = path_1.default.join(videoDir, 'summary_full.md'); fs_1.default.writeFileSync(fpath, markdown, 'utf8'); // Collect files to copy to _latest directory const filesToLatest = [ { source: fpath, name: 'summary_full.md' } ]; if (!args.noSaveTranscript) { const txtPath = path_1.default.join(videoDir, 'transcript.txt'); try { const plain = String(caps.transcript || '').trim(); fs_1.default.writeFileSync(txtPath, plain, 'utf8'); filesToLatest.push({ source: txtPath, name: 'transcript.txt' }); } catch { } } // Lightweight metadata for parity with yt-summary try { const meta = { url, videoId: caps.videoId, title: caps.title, createdAt: new Date().toISOString(), provider: choice.provider ?? 'none', }; const metaPath = path_1.default.join(videoDir, 'metadata.json'); fs_1.default.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8'); filesToLatest.push({ source: metaPath, name: 'metadata.json' }); } catch { } // Update the _latest directory with all files (0, utils_1.writeToLatestDir)(outputDir, filesToLatest); if (args.json) { process.stdout.write(JSON.stringify({ status: 'ok', path: fpath, provider: choice.provider, url, videoId: caps.videoId, title: caps.title, }, null, 2) + '\n'); } else { process.stdout.write(markdown + '\n'); } } main().catch((err) => { process.stderr.write(`Unexpected error: ${err?.message || err}\n`); process.exitCode = 1; }); // Friendly provider error messages are formatted in providers.formatProviderError //# sourceMappingURL=cli-old.js.map