summarizely-cli
Version:
YouTube summarizer that respects your existing subscriptions. No API keys required.
386 lines (384 loc) • 15.6 kB
JavaScript
;
/*
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