UNPKG

@willh/gemini-translator

Version:

A tool to translate SRT, WebVTT, ASS and Markdown files from English to Traditional Chinese using Google Gemini API

1,111 lines (986 loc) 42.7 kB
#!/usr/bin/env node // Gemini 英翻中(繁體)翻譯器 import fs from 'fs'; import path from 'path'; import axios from 'axios'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import promisePool from './promisePool.js'; const BATCH_SIZE = 10; const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'; const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; const MAX_RETRY_ATTEMPTS = 3; function parseArgs() { return yargs(hideBin(process.argv)) .usage('用法: npx @willh/gemini-translator --input <input.srt> [--output <output.srt>] [--model <model>] [--autofix]') .option('input', { alias: 'i', demandOption: true, describe: '輸入檔案路徑 (支援 .srt, .vtt, .ass, .md)', type: 'string' }) .option('output', { alias: 'o', describe: '輸出檔案路徑,預設根據輸入檔案自動產生。可指定不同格式的副檔名進行格式轉換', type: 'string' }) .option('model', { alias: 'm', describe: 'Gemini 模型,預設為 gemini-2.5-flash-preview-05-20', type: 'string', default: DEFAULT_MODEL }) .option('autofix', { describe: '自動修正字幕序號不連續問題 (適用於 SRT 和 WebVTT)', type: 'boolean', default: false }) .example('npx @willh/gemini-translator --input input.srt', '將 input.srt 翻譯為 input.zh.srt') .example('npx @willh/gemini-translator -i input.vtt', '翻譯 WebVTT 檔案') .example('npx @willh/gemini-translator -i input.ass -o output.ass', '翻譯 ASS 檔案') .example('npx @willh/gemini-translator -i input.md', '翻譯 Markdown 檔案') .example('npx @willh/gemini-translator -i input.srt -o output.ass', '將 SRT 翻譯並轉換為 ASS 格式') .example('npx @willh/gemini-translator -i input.vtt -o output.srt', '將 WebVTT 翻譯並轉換為 SRT 格式') .example('npx @willh/gemini-translator -i input.srt --autofix', '自動修正 SRT 字幕序號不連續問題') .example('npx @willh/gemini-translator -i input.vtt --autofix', '自動修正 WebVTT 字幕序號不連續問題') .help('h') .alias('h', 'help') .wrap(null) .parse(); } function parseSRT(content) { // 解析 SRT,回傳 [{index, time, text}] // 若結尾無多餘換行,補一個換行,確保最後一條字幕能被分割 if (!content.match(/\r?\n\s*$/)) { content += '\n'; } const blocks = content.split(/(?:\r?\n){2,}/); return blocks.map(block => { const lines = block.split(/\r?\n/); if (lines.length < 3) return null; const index = lines[0].trim(); const time = lines[1].trim(); const text = lines.slice(2).join('\n').trim(); if (!index || !time || !text) return null; return { index, time, text }; }).filter(Boolean); } function serializeSRT(blocks) { return blocks.map(b => `${b.index}\n${b.time}\n${b.text}\n`).join('\n'); } function parseWebVTT(content) { // 解析 WebVTT,回傳 [{index, time, text}] // 分割成段落 const segments = content.split(/\n\s*\n/); const blocks = []; for (const segment of segments) { const lines = segment.trim().split(/\r?\n/); if (lines.length === 0 || lines[0].trim() === 'WEBVTT') { continue; } let index = null; let timeIndex = -1; // 尋找時間碼行 for (let i = 0; i < lines.length; i++) { if (lines[i].includes(' --> ')) { timeIndex = i; break; } } if (timeIndex === -1) { continue; // 沒有找到時間碼,跳過這個段落 } // 檢查時間碼前面是否有索引 if (timeIndex > 0) { const potentialIndex = lines[timeIndex - 1].trim(); if (!isNaN(parseInt(potentialIndex, 10)) && String(parseInt(potentialIndex, 10)) === potentialIndex) { index = potentialIndex; } } const time = lines[timeIndex].trim(); const textLines = lines.slice(timeIndex + 1); const text = textLines.join('\n').trim(); if (text) { blocks.push({ index: index, time: time, text: text }); } } // 為沒有索引的塊分配順序索引 let autoIndex = 1; for (const block of blocks) { if (!block.index) { block.index = String(autoIndex); } autoIndex++; } return blocks; } function serializeWebVTT(blocks) { let result = 'WEBVTT\n\n'; result += blocks.map(b => { if (b.index) { return `${b.index}\n${b.time}\n${b.text}`; } else { return `${b.time}\n${b.text}`; } }).join('\n\n'); return result; } function parseASS(content) { // 解析 ASS,回傳 [{time, text}] (ASS 沒有序號) const lines = content.split(/\r?\n/); const blocks = []; let inEvents = false; let formatLine = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '[Events]') { inEvents = true; continue; } if (trimmed.startsWith('[') && trimmed !== '[Events]') { inEvents = false; continue; } if (inEvents && trimmed.startsWith('Format:')) { formatLine = trimmed.substring(7).trim(); continue; } if (inEvents && trimmed.startsWith('Dialogue:')) { const dialogueLine = trimmed.substring(9).trim(); const parts = dialogueLine.split(','); if (parts.length >= 10) { const start = parts[1].trim(); const end = parts[2].trim(); const text = parts.slice(9).join(',').trim(); // Remove ASS formatting tags const cleanText = text.replace(/\{[^}]*\}/g, '').replace(/\\N/g, '\n'); if (cleanText) { blocks.push({ time: `${start} --> ${end}`, text: cleanText }); } } } } return blocks; } function serializeASS(blocks, originalContent = '') { // Extract header from original content or use default let header = ''; if (originalContent) { const lines = originalContent.split(/\r?\n/); let inEvents = false; for (const line of lines) { if (line.trim() === '[Events]') { inEvents = true; header += line + '\n'; continue; } if (!inEvents) { header += line + '\n'; } if (inEvents && line.trim().startsWith('Format:')) { header += line + '\n'; break; } } } else { // Default ASS header header = `[Script Info] Title: Converted from WebVTT ScriptType: v4.00+ WrapStyle: 0 PlayResX: 1280 PlayResY: 720 ScaledBorderAndShadow: yes [V4+ Styles] Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding Style: Default, 微軟正黑體,48,&H0080FFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,1,0,1,2,0,2,1,1,40,1 Style: Secondary,Helvetica,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,2,1,1,40,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text `; } const dialogues = blocks.map(b => { const [start, end] = b.time.split(' --> '); const text = b.text.replace(/\n/g, '\\N'); return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`; }); return header + dialogues.join('\n') + '\n'; } function parseMarkdown(content) { // Parse Markdown content and return chunks for translation // Each chunk is treated as a block with text content // For files larger than 1000 bytes, split by lines const chunks = []; if (Buffer.byteLength(content, 'utf8') > 1000) { // Split large files by lines, but keep related content together const lines = content.split(/\r?\n/); let currentChunk = ''; let chunkIndex = 1; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const testChunk = currentChunk + (currentChunk ? '\n' : '') + line; // If adding this line would exceed reasonable chunk size (500 bytes), // or we hit a natural break point (empty line after content) if (Buffer.byteLength(testChunk, 'utf8') > 500 && currentChunk) { // Save current chunk if it has content if (currentChunk) { chunks.push({ index: String(chunkIndex++), text: currentChunk }); } currentChunk = line; } else { currentChunk = testChunk; } } // Add the last chunk if (currentChunk) { chunks.push({ index: String(chunkIndex), text: currentChunk }); } } else { // Small files are treated as single chunk chunks.push({ index: '1', text: content.trim() }); } return chunks; } function serializeMarkdown(blocks) { // Reconstruct Markdown content from translated blocks return blocks.map(b => b.text).join('\n'); } function parseSubtitle(content, type) { switch (type) { case 'srt': return parseSRT(content); case 'webvtt': return parseWebVTT(content); case 'ass': return parseASS(content); case 'md': return parseMarkdown(content); default: throw new Error(`不支援的字幕格式: ${type}`); } } function serializeSubtitle(blocks, type, originalContent = '') { switch (type) { case 'srt': return serializeSRT(blocks); case 'webvtt': return serializeWebVTT(blocks); case 'ass': // Only pass originalContent if it's already ASS format const isOriginalASS = originalContent && originalContent.includes('[Script Info]'); return serializeASS(blocks, isOriginalASS ? originalContent : ''); case 'md': return serializeMarkdown(blocks); default: throw new Error(`不支援的字幕格式: ${type}`); } } function generateOutputPath(inputPath, type) { const ext = path.extname(inputPath); const baseName = inputPath.replace(ext, ''); switch (type) { case 'srt': return `${baseName}.zh.srt`; case 'webvtt': return `${baseName}.zh.vtt`; case 'ass': return `${baseName}.zh.ass`; case 'md': return `${baseName}.zh.md`; default: return `${baseName}.zh${ext}`; } } function detectSubtitleType(filePath) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case '.srt': return 'srt'; case '.vtt': case '.webvtt': return 'webvtt'; case '.ass': case '.ssa': return 'ass'; case '.md': return 'md'; default: throw new Error(`不支援的字幕檔案格式: ${ext}。支援的格式: .srt, .vtt, .webvtt, .ass, .ssa, .md`); } } function checkSequentialTimestamps(blocks) { let prev = null; for (let i = 0; i < blocks.length; ++i) { const b = blocks[i]; if (!b.time) { console.warn(`[checkSequentialTimestamps] Block ${i + 1} 缺少時間碼:`, b); continue; } const [start] = b.time.split(' --> '); if (!start) { console.warn(`[checkSequentialTimestamps] Block ${i + 1} 時間碼格式錯誤:`, b.time); continue; } if (prev && start < prev) { console.error(`[checkSequentialTimestamps] 時間碼順序錯誤: Block ${i} (${prev}) -> Block ${i + 1} (${start})`); return false; } prev = start; } // console.log('[checkSequentialTimestamps] 時間碼順序檢查通過'); return true; } /** * 檢查原始 Markdown 和翻譯後 Markdown 的格式是否一致 * @param {Array} originalBlocks - 原始 Markdown 區塊 * @param {Array} translatedBlocks - 翻譯後 Markdown 區塊 * @returns {Object} 檢查結果 { isValid: boolean, errors: Array } */ function checkMarkdownFormat(originalBlocks, translatedBlocks) { const errors = []; // 檢查區塊數量是否一致 if (originalBlocks.length !== translatedBlocks.length) { errors.push(`區塊數量不一致: 原始 ${originalBlocks.length} 個,翻譯後 ${translatedBlocks.length} 個`); return { isValid: false, errors }; } // 逐一檢查每個區塊的格式 for (let i = 0; i < originalBlocks.length; i++) { const original = originalBlocks[i].text; const translated = translatedBlocks[i].text; // 檢查標題格式 (# ## ### 等) const originalHeaders = extractMarkdownHeaders(original); const translatedHeaders = extractMarkdownHeaders(translated); if (originalHeaders.length !== translatedHeaders.length) { errors.push(`區塊 ${i + 1}: 標題數量不一致 (原始: ${originalHeaders.length}, 翻譯: ${translatedHeaders.length})`); } else { for (let j = 0; j < originalHeaders.length; j++) { if (originalHeaders[j].level !== translatedHeaders[j].level) { errors.push(`區塊 ${i + 1}: 標題層級不一致 (位置 ${j + 1}, 原始: ${originalHeaders[j].level}, 翻譯: ${translatedHeaders[j].level})`); } } } // 檢查列表格式 const originalLists = extractMarkdownLists(original); const translatedLists = extractMarkdownLists(translated); if (originalLists.length !== translatedLists.length) { errors.push(`區塊 ${i + 1}: 列表項目數量不一致 (原始: ${originalLists.length}, 翻譯: ${translatedLists.length})`); } else { for (let j = 0; j < originalLists.length; j++) { if (originalLists[j].type !== translatedLists[j].type) { errors.push(`區塊 ${i + 1}: 列表類型不一致 (位置 ${j + 1}, 原始: ${originalLists[j].type}, 翻譯: ${translatedLists[j].type})`); } if (originalLists[j].level !== translatedLists[j].level) { errors.push(`區塊 ${i + 1}: 列表層級不一致 (位置 ${j + 1}, 原始: ${originalLists[j].level}, 翻譯: ${translatedLists[j].level})`); } } } // 檢查程式碼區塊 const originalCodeBlocks = extractMarkdownCodeBlocks(original); const translatedCodeBlocks = extractMarkdownCodeBlocks(translated); if (originalCodeBlocks.length !== translatedCodeBlocks.length) { errors.push(`區塊 ${i + 1}: 程式碼區塊數量不一致 (原始: ${originalCodeBlocks.length}, 翻譯: ${translatedCodeBlocks.length})`); } else { for (let j = 0; j < originalCodeBlocks.length; j++) { if (originalCodeBlocks[j].language !== translatedCodeBlocks[j].language) { errors.push(`區塊 ${i + 1}: 程式碼語言不一致 (位置 ${j + 1}, 原始: "${originalCodeBlocks[j].language}", 翻譯: "${translatedCodeBlocks[j].language}")`); } if (originalCodeBlocks[j].type !== translatedCodeBlocks[j].type) { errors.push(`區塊 ${i + 1}: 程式碼區塊類型不一致 (位置 ${j + 1}, 原始: ${originalCodeBlocks[j].type}, 翻譯: ${translatedCodeBlocks[j].type})`); } } } // 檢查連結格式 const originalLinks = extractMarkdownLinks(original); const translatedLinks = extractMarkdownLinks(translated); if (originalLinks.length !== translatedLinks.length) { errors.push(`區塊 ${i + 1}: 連結數量不一致 (原始: ${originalLinks.length}, 翻譯: ${translatedLinks.length})`); } else { for (let j = 0; j < originalLinks.length; j++) { if (originalLinks[j].url !== translatedLinks[j].url) { errors.push(`區塊 ${i + 1}: 連結 URL 不一致 (位置 ${j + 1}, 原始: "${originalLinks[j].url}", 翻譯: "${translatedLinks[j].url}")`); } } } // 檢查特殊語法(如 ::: tip 等) const originalSpecial = extractMarkdownSpecialSyntax(original); const translatedSpecial = extractMarkdownSpecialSyntax(translated); if (originalSpecial.length !== translatedSpecial.length) { errors.push(`區塊 ${i + 1}: 特殊語法數量不一致 (原始: ${originalSpecial.length}, 翻譯: ${translatedSpecial.length})`); } else { for (let j = 0; j < originalSpecial.length; j++) { if (originalSpecial[j].type !== translatedSpecial[j].type) { errors.push(`區塊 ${i + 1}: 特殊語法類型不一致 (位置 ${j + 1}, 原始: "${originalSpecial[j].type}", 翻譯: "${translatedSpecial[j].type}")`); } } } } return { isValid: errors.length === 0, errors }; } /** * 提取 Markdown 標題 * @param {string} text - Markdown 文本 * @returns {Array} 標題列表,包含層級信息 */ function extractMarkdownHeaders(text) { const headers = []; const lines = text.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('#')) { const match = trimmed.match(/^(#{1,6})\s+/); if (match) { headers.push({ level: match[1].length, text: trimmed.substring(match[0].length).trim() }); } } } return headers; } /** * 提取 Markdown 列表項目 * @param {string} text - Markdown 文本 * @returns {Array} 列表項目,包含類型和層級信息 */ function extractMarkdownLists(text) { const lists = []; const lines = text.split('\n'); for (const line of lines) { const trimmed = line.trim(); // 無序列表 (*, -, +) const unorderedMatch = trimmed.match(/^(\s*)([-*+])\s+/); if (unorderedMatch) { lists.push({ type: 'unordered', level: Math.floor(unorderedMatch[1].length / 4) + 1, // 假設每 4 個空格為一層 marker: unorderedMatch[2] }); continue; } // 有序列表 (1., 2., etc.) const orderedMatch = trimmed.match(/^(\s*)(\d+\.)\s+/); if (orderedMatch) { lists.push({ type: 'ordered', level: Math.floor(orderedMatch[1].length / 4) + 1, marker: orderedMatch[2] }); } } return lists; } /** * 提取 Markdown 程式碼區塊 * @param {string} text - Markdown 文本 * @returns {Array} 程式碼區塊,包含語言和類型信息 */ function extractMarkdownCodeBlocks(text) { const codeBlocks = []; const lines = text.split('\n'); let inCodeBlock = false; let currentBlock = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 檢查行內程式碼 `code` const inlineCodeMatches = line.match(/`[^`]+`/g); if (inlineCodeMatches) { inlineCodeMatches.forEach(() => { codeBlocks.push({ type: 'inline', language: '', content: '' }); }); } // 檢查程式碼區塊 ``` if (line.trim().startsWith('```')) { if (!inCodeBlock) { // 開始程式碼區塊 const language = line.trim().substring(3).trim(); currentBlock = { type: 'block', language: language, content: '' }; inCodeBlock = true; } else { // 結束程式碼區塊 if (currentBlock) { codeBlocks.push(currentBlock); currentBlock = null; } inCodeBlock = false; } } else if (inCodeBlock && currentBlock) { currentBlock.content += line + '\n'; } } return codeBlocks; } /** * 提取 Markdown 連結 * @param {string} text - Markdown 文本 * @returns {Array} 連結列表,包含 URL 和文本 */ function extractMarkdownLinks(text) { const links = []; // 標準連結格式 [text](url) const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; let match; while ((match = linkRegex.exec(text)) !== null) { links.push({ text: match[1], url: match[2] }); } return links; } /** * 提取 Markdown 特殊語法(如 VuePress 的 ::: tip 等) * @param {string} text - Markdown 文本 * @returns {Array} 特殊語法列表 */ function extractMarkdownSpecialSyntax(text) { const special = []; const lines = text.split('\n'); for (const line of lines) { const trimmed = line.trim(); // VuePress 容器語法 ::: type if (trimmed.startsWith(':::')) { const match = trimmed.match(/^:::\s*(\w+)/); if (match) { special.push({ type: match[1], syntax: 'vuepress-container' }); } } // 其他特殊語法可以在這裡添加 } return special; } // 重試包裝函數 async function withRetry(asyncFunction, maxAttempts = MAX_RETRY_ATTEMPTS, description = '操作') { let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await asyncFunction(); } catch (error) { lastError = error; if (attempt < maxAttempts) { console.error(`\n${description}失敗 (第 ${attempt}/${maxAttempts} 次嘗試): ${error.message}`); console.log(`等待 ${attempt} 秒後重試...`); await new Promise(resolve => setTimeout(resolve, attempt * 1000)); } } } // 所有重試都失敗,拋出最後一個錯誤 console.error(`\n${description}${maxAttempts} 次嘗試後仍然失敗`); throw lastError; } // 修改 translateBatch,於 prompt 加入摘要 context async function translateBatch(texts, apiKey, model, contentType = 'subtitle') { // 若有摘要,加入 context 以提升翻譯品質 let contextPrompt = ''; if (typeof globalThis.translationSummary === 'string' && globalThis.translationSummary) { contextPrompt = `\n\n【主題摘要】\n${globalThis.translationSummary}\n`; } const contentTypeText = contentType === 'markdown' ? 'markdown content' : 'subtitle content'; const formatInstructions = contentType === 'markdown' ? 'Preserve markdown formatting like headers, links, code blocks, and other markdown syntax.' : ''; let prompt = `The following text is a string array containing ${contentTypeText}. Translate each element in this array from English to Traditional Chinese (zh-tw). If the input has 10 elements, the output should also be a string array with 10 elements. ${formatInstructions} Do not add any extra text or formatting beyond the translation. Make sure the output is a valid JSON array. Here is the context of the translation task: \`\`\`${contextPrompt}\`\`\``; prompt += ` <notes> Use the following term mappings: - creating = 建立 - create = 建立 - 創建 = 建立 - 创建 = 建立 - 質量 (quality) = 品質 - 編程 (coding) (programming) = 程式設計 - object = 物件 - queue = 佇列 - stack = 堆疊 - information = 資訊 - invocation = 呼叫 - code = 程式碼 - running = 執行 - library = 函式庫 - schematics = 原理圖 - building = 建構 - Setting up = 設定 - package = 套件 - video = 影片 - for loop = for 迴圈 - class = 類別 - Concurrency = 平行處理 - Transaction = 交易 - Transactional = 交易式 - Code Snippet = 程式碼片段 - Code Generation = 程式碼產生器 - Any Class = 任意類別 - Scalability = 延展性 - Dependency Package = 相依套件 - Dependency Injection = 相依性注入 - Reserved Keywords = 保留字 - Metadata = Metadata - Clone = 複製 - Memory = 記憶體 - Built-in = 內建 - Global = 全域 - Compatibility = 相容性 - Function = 函式 - Refresh = 重新整理 - document = 文件 - example = 範例 - demo = 展示 - quality = 品質 - tutorial = 指南 - recipes = 秘訣 - data source = 資料來源 - premium requests = 進階請求 - remote = 遠端 - settings = 設定 - project = 專案 - database = 資料庫 - cache = 快取 - caching = 快取 - base model = 基礎模型 - demonstration = 展示 - demo = 展示 - creator = 創作者 - integration = 整合 - character = 字元 Do not translate the following terms: - Semantic Kernel - Plugins - LLM </notes>`; const body = { contents: [ { role: 'user', parts: [{ text: prompt }, { text: JSON.stringify(texts) }] }, ], generationConfig: { responseMimeType: 'application/json', responseSchema: { type: 'array', minItems: texts.length, maxItems: texts.length, items: { type: 'string' } } } }; const url = `${API_URL}/${model}:generateContent?key=${apiKey}`; const resp = await axios.post(url, body, { headers: { 'Content-Type': 'application/json' } }); // Gemini API 可能回傳陣列(每個字幕一個物件) let result = resp.data; // console.log('API 回傳:', JSON.stringify(result, null, 2)); // 只處理單一物件回傳(結構化 JSON) // 直接回傳 resp.data(應為 JSON 陣列) function extractStrings(val) { // 遞迴解析直到取得 string 陣列 if (Array.isArray(val)) { return val.flatMap(extractStrings); } if (typeof val === 'object' && val !== null) { // 處理 Gemini API 回傳格式 if (val.candidates && Array.isArray(val.candidates)) { return val.candidates.flatMap(extractStrings); } if (val.content && val.content.parts && Array.isArray(val.content.parts)) { return val.content.parts.flatMap(extractStrings); } if (typeof val.text === 'string') { // 嘗試將 text 欄位解析為 JSON 陣列 try { const arr = JSON.parse(val.text); if (Array.isArray(arr)) return extractStrings(arr); } catch (e) { } return [val.text]; } // 其他物件,轉為 JSON 字串 return [JSON.stringify(val)]; } if (typeof val === 'string') { // 嘗試解析為 JSON 陣列 try { const arr = JSON.parse(val); if (Array.isArray(arr)) { // 遞迴展開所有元素,並過濾掉非字串 return arr.flatMap(e => typeof e === 'string' ? [e] : extractStrings(e)); } } catch (e) { } return [val]; } return [String(val)]; } if (Array.isArray(result)) { return extractStrings(result); } // fallback: 舊格式處理 if (result && result.candidates && result.candidates[0] && result.candidates[0].content && result.candidates[0].content.parts) { let text = result.candidates[0].content.parts.map(p => p.text).join(''); text = text.replace(/^```json\s*/i, '').replace(/\s*```\s*$/i, ''); try { const arr = JSON.parse(text); if (Array.isArray(arr)) return arr.map(x => String(x)); } catch (e) { let lines = text.split(/\r?\n\r?\n|\r?\n/).filter(l => l.trim()); return lines; } } const err = new Error('Gemini API 回傳格式錯誤'); err.raw = result; throw err; } async function main() { const argv = parseArgs(); const inputPath = argv.input; const inputType = detectSubtitleType(inputPath); // Determine output type and path let outputType = inputType; let outputPath; if (argv.output) { outputPath = argv.output; try { outputType = detectSubtitleType(outputPath); } catch (e) { // If output file extension is not recognized, keep input type outputType = inputType; } } else { outputPath = generateOutputPath(inputPath, inputType); outputType = inputType; } const model = argv.model || DEFAULT_MODEL; const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { console.error('請設定 GEMINI_API_KEY 環境變數'); process.exit(1); } if (!fs.existsSync(inputPath)) { console.error('找不到輸入檔案:', inputPath); process.exit(1); } console.log(`檢測到輸入檔案格式: ${inputType.toUpperCase()}`); if (inputType !== outputType) { console.log(`將轉換為輸出格式: ${outputType.toUpperCase()}`); } const subtitleContent = fs.readFileSync(inputPath, 'utf8'); const blocks = parseSubtitle(subtitleContent, inputType); // 檢查 index 連續性,若有缺漏則顯示有問題的 time code 並停止,或自動修正 (適用於 SRT 和 WebVTT) if (inputType === 'srt' || inputType === 'webvtt') { const indices = blocks.map(b => parseInt(b.index, 10)); let broken = []; for (let i = 1; i < indices.length; ++i) { if (indices[i] !== indices[i - 1] + 1) { broken.push({ missing: indices[i - 1] + 1, prevTime: blocks[i - 1].time, nextTime: blocks[i].time, pos: i }); } } if (broken.length > 0) { if (argv.autofix) { console.warn('發現字幕序號不連續,自動修正中...'); // 重新編號 blocks for (let i = 0; i < blocks.length; ++i) { blocks[i].index = String(i + 1); } // 修正後直接覆蓋原檔,根據格式使用對應的序列化函數 const fixedContent = inputType === 'srt' ? serializeSRT(blocks) : serializeWebVTT(blocks); fs.writeFileSync(inputPath, fixedContent, 'utf8'); console.log('已自動修正並覆蓋原始檔案,請重新執行本程式。'); process.exit(0); } else { console.error('字幕序號不連續,發現缺漏:'); broken.forEach(b => { console.error(`缺少序號 ${b.missing},前一字幕時間碼: ${b.prevTime},下一字幕時間碼: ${b.nextTime}`); }); console.error('\n提示:您可以使用 --autofix 選項來自動修正字幕序號不連續問題'); const fileExt = inputType === 'srt' ? 'srt' : 'vtt'; console.error(`例如:npx @willh/gemini-translator --input input.${fileExt} --autofix`); process.exit(1); } } } // 產生摘要以提升翻譯品質 const allTexts = blocks.map(b => b.text).join('\n'); let summary = ''; // 使用重試機制產生摘要 try { console.log('正在產生內容摘要以提升翻譯品質...'); const contentType = inputType === 'md' ? '文件' : '字幕'; summary = await withRetry(async () => { const summaryPrompt = `請閱讀以下英文${contentType}內容,並以繁體中文摘要其主題、內容重點、專有名詞、人物、背景、風格等,摘要長度 100-200 字,僅回傳摘要內容:\n${allTexts}`; const summaryBody = { contents: [ { role: 'user', parts: [{ text: summaryPrompt }] }, ], generationConfig: { responseMimeType: 'text/plain', } }; const summaryUrl = `${API_URL}/${model}:generateContent?key=${apiKey}`; const resp = await axios.post(summaryUrl, summaryBody, { headers: { 'Content-Type': 'application/json' } }); // 嘗試從 Gemini API 回傳中取得摘要 let result = ''; if (resp.data && resp.data.candidates && resp.data.candidates[0] && resp.data.candidates[0].content && resp.data.candidates[0].content.parts) { result = resp.data.candidates[0].content.parts.map(p => p.text).join(''); } else if (resp.data && resp.data.candidates && resp.data.candidates[0] && resp.data.candidates[0].content && resp.data.candidates[0].content.text) { result = resp.data.candidates[0].content.text; } if (!result || result.trim() === '') { throw new Error('API 未回傳有效的摘要內容'); } return result; }, MAX_RETRY_ATTEMPTS, '摘要產生'); if (summary) { // console.log('摘要產生完成:', summary); } } catch (e) { console.warn('產生摘要失敗,將直接進行翻譯。', e.message); summary = ''; } // 將摘要存入 global 以便後續翻譯任務使用 globalThis.translationSummary = summary; let translatedBlocks = []; const itemType = inputType === 'md' ? '段落' : '條字幕'; console.log(`共 ${blocks.length} ${itemType},分批處理中...`); // 將 blocks 分批 const batches = []; for (let i = 0; i < blocks.length; i += BATCH_SIZE) { const batch = blocks.slice(i, i + BATCH_SIZE); batches.push(batch); } // 進度追蹤 let completedTasks = 0; const totalTasks = batches.length; // 建立任務陣列 const tasks = batches.map((batch, batchIdx) => async () => { const texts = batch.map(b => b.text); // 使用重試機制進行翻譯 const translations = await withRetry(async () => { // console.error('翻譯內容:', JSON.stringify(texts, null, 2)); const contentType = inputType === 'md' ? 'markdown' : 'subtitle'; const result = await translateBatch(texts, apiKey, model, contentType); // console.error('翻譯結果:', JSON.stringify(result, null, 2)); // 檢核翻譯結果 if (!Array.isArray(result) || result.length !== batch.length) { const itemType = inputType === 'md' ? '段落' : '字幕'; const error = new Error(`翻譯數量與原始${itemType}數量不符 (input: ${batch.length}, result: ${Array.isArray(result) ? result.length : 'N/A'})`); if (Array.isArray(result)) { console.error('翻譯結果:', JSON.stringify(result, null, 2)); } throw error; } return result; }, MAX_RETRY_ATTEMPTS, `批次 ${batchIdx + 1} 翻譯`); // 更新進度 completedTasks++; const startIdx = batchIdx * BATCH_SIZE + 1; const endIdx = Math.min((batchIdx + 1) * BATCH_SIZE, blocks.length); process.stdout.write(`\r翻譯進度: ${completedTasks}/${totalTasks} 批次完成 (第 ${startIdx}-${endIdx} 條已完成)...`); // 回傳本 batch 的翻譯結果 return translations; }); console.log('開始平行處理翻譯任務,最多同時執行 20 個任務...'); // 平行處理,最多 20 個同時執行 const allTranslations = await promisePool(tasks, 20); process.stdout.write('\n'); // 確保下一行從新行開始 console.log('所有翻譯任務已完成,開始合併翻譯結果...'); // 合併所有翻譯結果 const flatTranslations = allTranslations.flat(); // 將翻譯結果還原回 blocks 結構 translatedBlocks = blocks.map((block, idx) => ({ ...block, text: flatTranslations[idx] || '' })); // console.log('翻譯結果合併完成', translatedBlocks); // 檢查時間碼順序 (僅適用於 SRT 和 WebVTT) if (outputType !== 'ass' && outputType !== 'md') { console.log('檢查時間碼順序...'); console.log(); if (!checkSequentialTimestamps(translatedBlocks)) { console.error('時間碼順序錯誤'); process.exit(1); } console.log('時間碼順序檢查通過,準備寫入輸出檔案...'); } else if (inputType === 'md') { // 檢查 Markdown 格式一致性,如果失敗則重新翻譯 let retryCount = 0; const maxRetries = 3; let formatCheckPassed = false; while (!formatCheckPassed && retryCount < maxRetries) { console.log('檢查 Markdown 格式一致性...'); console.log(); const formatCheck = checkMarkdownFormat(blocks, translatedBlocks); if (!formatCheck.isValid) { retryCount++; console.error(`Markdown 格式檢查失敗 (第 ${retryCount} 次):`); formatCheck.errors.forEach(error => { console.error(` - ${error}`); }); if (retryCount < maxRetries) { console.log('正在重新翻譯...'); // 進度追蹤 let completedRetranslations = 0; const totalRetranslations = batches.length; // 重新翻譯所有區塊 const retranslationTasks = batches.map((batch, batchIdx) => async () => { const texts = batch.map(b => b.text); const translations = await withRetry(async () => { const contentType = inputType === 'md' ? 'markdown' : 'subtitle'; const result = await translateBatch(texts, apiKey, model, contentType); if (!Array.isArray(result) || result.length !== batch.length) { const error = new Error(`重新翻譯數量不符 (input: ${batch.length}, result: ${Array.isArray(result) ? result.length : 'N/A'})`); throw error; } return result; }, MAX_RETRY_ATTEMPTS, `重新翻譯批次 ${batchIdx + 1}`); // 更新進度 completedRetranslations++; const startIdx = batchIdx * BATCH_SIZE + 1; const endIdx = Math.min((batchIdx + 1) * BATCH_SIZE, blocks.length); process.stdout.write(`\r重新翻譯進度: ${completedRetranslations}/${totalRetranslations} 批次完成 (第 ${startIdx}-${endIdx} 條已完成)...`); return translations; }); // 執行重新翻譯 const allRetranslations = await promisePool(retranslationTasks, 20); const flatRetranslations = allRetranslations.flat(); // 更新翻譯結果 translatedBlocks = blocks.map((block, idx) => ({ ...block, text: flatRetranslations[idx] || '' })); process.stdout.write('\n'); // 確保下一行從新行開始 console.log('重新翻譯完成,再次檢查格式...'); } else { console.error(`已達到最大重試次數 (${maxRetries}),格式檢查仍然失敗`); console.log('將繼續處理,但可能存在格式不一致問題'); formatCheckPassed = true; // 強制退出迴圈 } } else { formatCheckPassed = true; console.log('Markdown 格式檢查通過,準備寫入輸出檔案...'); } } } else { // console.log('ASS 格式無需檢查時間碼順序,準備寫入輸出檔案...'); } fs.writeFileSync(outputPath, serializeSubtitle(translatedBlocks, outputType, subtitleContent), 'utf8'); console.log(`\n翻譯完成,已寫入 ${outputPath}`); } // console.log(process.argv) // console.log(import.meta.url) // console.log(`file:///${process.argv[1].replace(/\\/g, '/')}`) // console.log(`file://${process.argv[1]}`) // console.log(path.basename(process.argv[1])) // Check if this module is being run directly (not imported) // Enhanced check for direct execution, including npx (which may use main.js or the package entry) const scriptName = path.basename(process.argv[1] || ''); const importUrl = import.meta.url; // console.log(scriptName) // console.log(importUrl) // Handles cases like: .../bin/gemini-translator, .../bin/gemini-translator.js, .../main.js (npx) const isDirectRun = importUrl.endsWith(`/${scriptName}`) || importUrl.endsWith(`/${scriptName}.js`) || importUrl.endsWith('/main.js') || importUrl.endsWith('/main.mjs'); // Don't run if we're in a test environment if (isDirectRun && (!process.env.NODE_ENV || process.env.NODE_ENV !== 'test')) { main(); }