@willh/gemini-translator
Version:
A tool to translate SRT, WebVTT, ASS and Markdown files from English to Traditional Chinese using Google Gemini API
1,487 lines (1,307 loc) • 83.4 kB
JavaScript
#!/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-pro';
const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
const MAX_RETRY_ATTEMPTS = 10;
function parseArgs() {
return yargs(hideBin(process.argv))
.usage('用法: npx @willh/gemini-translator --input <input.srt> [--output <output.srt>] [--model <model>] [--autofix] [--debug]')
.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-pro', type: 'string', default: DEFAULT_MODEL }).option('autofix', { describe: '自動修正字幕序號不連續問題 (適用於 SRT 和 WebVTT)', type: 'boolean', default: false }).option('debug', { describe: '顯示詳細的除錯資訊,包括翻譯前後的完整內容比對', type: 'boolean', default: false })
.option('bytes-per-chunk', { describe: '每個區塊的最大位元數 (適用於 Markdown)', type: 'number', default: 3000 })
.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.md --bytes-per-chunk 5000', '翻譯 Markdown 並設定每個區塊 5000 bytes')
.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 字幕序號不連續問題')
.example('npx @willh/gemini-translator -i input.md --debug', '翻譯 Markdown 並顯示除錯資訊')
.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, bytesPerChunk) {
// 1. 先將所有 \r\n 都先改為 \n
content = content.replace(/\r\n/g, '\n');
// Regex for blocks
// 支援 ``` 與 ~~~ 的 code fence(需成對匹配)
const codeFenceRegex = /^(```|~~~)[\s\S]*?^\1\n?/gm;
const blockquoteRegex = /^(?:>[ \t]?.*(?:\n|$))+/gm;
const tableRegex = /^(?:\|.*\|(?:\n|$))+/gm;
const htmlTagRegex = /^(?:<([a-z][a-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>|<[a-z][a-z0-9]*\b[^>]*\/>)\n?/gmi;
const latexRegex = /^\$\$[\s\S]*?\$\$\n?/gm;
const listRegex = /^(?:(?:[ ]{0,3}(?:[-*+]|\d+\.))[ \t]+.*(?:\n|$)(?:[ \t].*(?:\n|$))*)+/gm;
const patterns = [
codeFenceRegex, // 4. Code fence
listRegex, // 3. List
blockquoteRegex, // 5. Blockquote
tableRegex, // 6. Table
htmlTagRegex, // 7. HTML Tag
latexRegex, // 8. LaTeX
];
let extractedBlocks = [];
patterns.forEach(regex => {
let match;
regex.lastIndex = 0;
while ((match = regex.exec(content)) !== null) {
extractedBlocks.push({ start: match.index, end: match.index + match[0].length, text: match[0] });
}
});
// Sort blocks by start index
extractedBlocks.sort((a, b) => a.start - b.start);
// Filter out overlapping blocks, keeping the first one found (usually the larger container)
const finalBlocks = [];
let lastEnd = -1;
for (const block of extractedBlocks) {
if (block.start >= lastEnd) {
finalBlocks.push(block);
lastEnd = block.end;
}
}
// 2. 將文字切為段落,保留原始分隔符(\n\n+)
const segments = []; // { text, sep }
let fileLeadingSep = '';
function addParagraphs(part) {
if (!part) return;
// 將前導的多重空白行視為上一段的分隔符(若無上一段則記錄為檔案開頭的前置空白)
const leading = part.match(/^\n\n+/);
let rest = part;
if (leading) {
if (segments.length > 0) {
segments[segments.length - 1].sep = (segments[segments.length - 1].sep || '') + leading[0];
} else {
fileLeadingSep += leading[0];
}
rest = part.slice(leading[0].length);
}
if (!rest) return;
const re = /\n\n+/g;
let last = 0;
let m;
while ((m = re.exec(rest)) !== null) {
const text = rest.slice(last, m.index);
const sep = m[0];
if (text.length > 0) {
segments.push({ text, sep });
} else {
if (segments.length > 0) {
segments[segments.length - 1].sep = (segments[segments.length - 1].sep || '') + sep;
} else {
fileLeadingSep += sep;
}
}
last = re.lastIndex;
}
const tail = rest.slice(last);
if (tail.length > 0) {
segments.push({ text: tail, sep: '' });
}
}
let cursor = 0;
for (const block of finalBlocks) {
const preceding = content.slice(cursor, block.start);
addParagraphs(preceding);
segments.push({ text: block.text, sep: '' });
cursor = block.end;
}
addParagraphs(content.slice(cursor));
// 3. 合併小段落,保留原始分隔符
const merged = [];
if (segments.length > 0) {
let current = { ...segments[0] };
for (let i = 1; i < segments.length; i++) {
const next = segments[i];
const combinedBytes = Buffer.byteLength(current.text + (current.sep || '') + next.text, 'utf8');
if (combinedBytes <= bytesPerChunk) {
current.text = current.text + (current.sep || '') + next.text;
current.sep = next.sep || '';
} else {
merged.push(current);
current = { ...next };
}
}
merged.push(current);
}
return merged.map((seg, index) => ({
index: String(index + 1),
text: seg.text,
sep: seg.sep || '',
leadingSep: index === 0 && fileLeadingSep ? fileLeadingSep : ''
}));
}
// Helper function to check if a line is part of a list
function isPartOfList(lines, index) {
const line = lines[index];
const trimmed = line.trim();
// Check if current line is a list item
if (trimmed.match(/^[-*+]\s/) || trimmed.match(/^\d+\.\s/)) {
return true;
}
// Check if current line is a continuation of a list item (indented)
if (line.match(/^\s+/) && trimmed !== '') {
// Look backward for the nearest list item, considering indentation levels
const currentIndent = line.match(/^\s*/)[0].length;
for (let i = index - 1; i >= 0; i--) {
const prevLine = lines[i];
const prevTrimmed = prevLine.trim();
// Skip empty lines
if (prevTrimmed === '') continue;
// Found a list item
if (prevTrimmed.match(/^[-*+]\s/) || prevTrimmed.match(/^\d+\.\s/)) {
return true;
}
// If we encounter a line with less indentation than current line,
// and it's not a list item, then we're not in a list
const prevIndent = prevLine.match(/^\s*/)[0].length;
if (prevIndent < currentIndent && !prevTrimmed.match(/^[-*+]\s/) && !prevTrimmed.match(/^\d+\.\s/)) {
// But continue if the previous line is also indented (could be nested list content)
if (prevIndent === 0) break;
}
}
}
return false;
}
// Helper function to check if a line is part of a header structure
function isPartOfHeader(lines, index) {
const line = lines[index];
// Current line is a header
if (line.trim().startsWith('#')) {
return true;
}
// Check for setext-style headers (underlined with = or -)
if (index + 1 < lines.length) {
const nextLine = lines[index + 1].trim();
if (nextLine.match(/^=+$/) || nextLine.match(/^-+$/)) {
return true;
}
}
return false;
}
// Helper function to check if there's an ongoing structure that shouldn't be broken
function hasOngoingStructure(lines, index) {
// Look ahead for immediate structure continuations
if (index + 1 < lines.length) {
const nextLine = lines[index + 1];
const trimmed = nextLine.trim();
// Next line is indented content (likely continuation)
if (nextLine.match(/^\s+\S/) && trimmed !== '') {
return true;
}
// Next line is a table separator or continuation
if (trimmed.includes('|') || trimmed.match(/^[-|:\s]+$/)) {
return true;
}
}
return false;
}
// Helper function to check if we're at a safe list boundary for chunking
function isAtListBoundary(lines, index) {
// If we're not in a list, it's always safe
if (!isPartOfList(lines, index)) {
return true;
}
// Check if the next lines continue the current list item
for (let i = index + 1; i < lines.length; i++) {
const nextLine = lines[i];
const nextTrimmed = nextLine.trim();
// Empty line - could be end of list item or just spacing
if (nextTrimmed === '') {
continue;
}
// If next non-empty line is a new list item at same level or header, we're at boundary
if (nextTrimmed.match(/^[-*+]\s/) || nextTrimmed.match(/^\d+\.\s/) ||
nextTrimmed.startsWith('#')) {
return true;
}
// If next non-empty line is indented (continuation of current list item), not at boundary
if (nextLine.match(/^\s+/) && nextTrimmed !== '') {
return false;
}
// If next line is not indented and not a list item, we're at boundary
if (!nextLine.match(/^\s+/)) {
return true;
}
}
// End of content, so we're at boundary
return true;
}
function serializeMarkdown(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) return '';
const prefix = blocks[0]?.leadingSep || '';
let out = prefix;
for (const b of blocks) {
const txt = b?.text || '';
const sep = b?.sep || '';
out += txt + sep;
}
return out;
}
function parseSubtitle(content, type, bytesPerChunk) {
switch (type) {
case 'srt':
return parseSRT(content);
case 'webvtt':
return parseWebVTT(content);
case 'ass':
return parseASS(content);
case 'md':
return parseMarkdown(content, bytesPerChunk);
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;
}
/**
* 顯示除錯比對資訊
* @param {Array} originalBlocks - 原始區塊
* @param {Array} translatedBlocks - 翻譯後區塊
* @param {string} title - 除錯標題
* @param {boolean} showDetails - 是否顯示詳細內容
*/
function showDebugComparison(originalBlocks, translatedBlocks, title, showDetails = false) {
console.error(`\n=== ${title} ===`);
console.error(`原始區塊數量: ${originalBlocks.length}`);
console.error(`翻譯區塊數量: ${translatedBlocks.length}`);
if (showDetails) {
console.error('\n原始區塊內容:');
originalBlocks.forEach((block, index) => {
const text = block.text || block.toString();
const preview = text.replace(/\n/g, '\\n').substring(0, 100);
console.error(` ${index + 1}. ${preview}${text.length > 100 ? '...' : ''}`);
});
console.error('\n翻譯區塊內容:');
translatedBlocks.forEach((block, index) => {
const text = block.text || block.toString();
const preview = text.replace(/\n/g, '\\n').substring(0, 100);
console.error(` ${index + 1}. ${preview}${text.length > 100 ? '...' : ''}`);
});
}
console.error(`=== ${title} 結束 ===\n`);
}
/**
* 顯示 Markdown 格式除錯資訊
* @param {Array} originalBlocks - 原始 Markdown 區塊
* @param {Array} translatedBlocks - 翻譯後 Markdown 區塊
* @param {Array} errors - 錯誤列表
* @param {boolean} isDebugMode - 是否為除錯模式
* @param {string} inputPath - 輸入檔案路徑
*/
function showMarkdownFormatDebug(originalBlocks, translatedBlocks, errors, isDebugMode, inputPath) {
if (!isDebugMode) return;
console.error('\n=== Markdown 格式檢查除錯資訊 ===');
console.error(`正在處理檔案: ${inputPath}`);
console.error(`發現 ${errors.length} 個格式問題:`);
errors.forEach((error, index) => {
console.error(` ${index + 1}. ${error}`);
});
console.error('\n詳細區塊比對:');
const maxBlocks = Math.max(originalBlocks.length, translatedBlocks.length);
for (let i = 0; i < maxBlocks; i++) {
console.error(`\n--- 區塊 ${i + 1} ---`);
if (i < originalBlocks.length) {
const originalText = originalBlocks[i].text || '';
console.error(`原始: ${originalText.replace(/\n/g, '\\n')}`);
} else {
console.error('原始: [不存在]');
}
if (i < translatedBlocks.length) {
const translatedText = translatedBlocks[i].text || '';
console.error(`翻譯: ${translatedText.replace(/\n/g, '\\n')}`);
} else {
console.error('翻譯: [不存在]');
}
}
console.error('\n=== Markdown 格式檢查除錯資訊結束 ===\n');
}
/**
* 檢查原始 Markdown 和翻譯後 Markdown 的格式是否一致
* @param {Array} originalBlocks - 原始 Markdown 區塊
* @param {Array} translatedBlocks - 翻譯後 Markdown 區塊
* @param {boolean} isDebugMode - 是否為除錯模式
* @param {string} inputPath - 輸入檔案路徑
* @returns {Object} 檢查結果 { isValid: boolean, errors: Array }
*/
function checkMarkdownFormat(originalBlocks, translatedBlocks, isDebugMode = false, inputPath = '') {
const errors = [];
// 檢查區塊數量是否一致
if (originalBlocks.length !== translatedBlocks.length) {
errors.push(`區塊數量不一致: 原始 ${originalBlocks.length} 個,翻譯後 ${translatedBlocks.length} 個`);
// 如果開啟除錯模式,顯示詳細比對
if (isDebugMode) {
showDebugComparison(originalBlocks, translatedBlocks, '區塊數量不一致詳細比對', true);
}
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++) {
const origLink = originalLinks[j];
const transLink = translatedLinks[j];
// Check URL consistency for links that have URLs
if (origLink.url && transLink.url && origLink.url !== transLink.url) {
errors.push(`區塊 ${i + 1}: 連結 URL 不一致 (位置 ${j + 1}, 原始: "${origLink.url}", 翻譯: "${transLink.url}")`);
}
// Check link type consistency
if (origLink.type !== transLink.type) {
errors.push(`區塊 ${i + 1}: 連結類型不一致 (位置 ${j + 1}, 原始: ${origLink.type}, 翻譯: ${transLink.type})`);
}
// Check reference consistency for reference-style links
if (origLink.ref && transLink.ref && origLink.ref !== transLink.ref) {
errors.push(`區塊 ${i + 1}: 連結參考不一致 (位置 ${j + 1}, 原始: "${origLink.ref}", 翻譯: "${transLink.ref}")`);
}
}
}
// 檢查特殊語法(如 ::: tip 等)
const originalSpecial = extractMarkdownSpecialSyntax(original);
const translatedSpecial = extractMarkdownSpecialSyntax(translated);
if (originalSpecial.length !== translatedSpecial.length) {
const specialSyntaxDiff = generateSpecialSyntaxDifference(originalSpecial, translatedSpecial, i + 1);
// 生成更詳細的錯誤訊息
const detailedError = generateDetailedSpecialSyntaxError(originalSpecial, translatedSpecial, i + 1);
errors.push(detailedError);
// 如果開啟除錯模式,顯示詳細的特殊語法差異
if (isDebugMode) {
console.error('\n' + specialSyntaxDiff);
}
} 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}")`);
}
}
}
}
const result = {
isValid: errors.length === 0,
errors
};
// 如果檢查失敗且開啟除錯模式,顯示詳細除錯資訊
if (!result.isValid && isDebugMode) {
showMarkdownFormatDebug(originalBlocks, translatedBlocks, errors, isDebugMode, inputPath);
}
return result;
}
/**
* 提取 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 leadingSpaces = line.match(/^(\s*)/)?.[1] || '';
// 無序列表 (*, -, +)
const unorderedMatch = trimmed.match(/^([-*+])\s+/);
if (unorderedMatch) {
// Calculate level based on actual indentation, supporting both 2 and 4 space styles
const level = leadingSpaces.length === 0 ? 1 :
Math.floor(leadingSpaces.length / (leadingSpaces.length >= 4 ? 4 : 2)) + 1;
lists.push({
type: 'unordered',
level: level,
marker: unorderedMatch[1],
indent: leadingSpaces.length
});
continue;
}
// 有序列表 (1., 2., etc.)
const orderedMatch = trimmed.match(/^(\d+\.)\s+/);
if (orderedMatch) {
// Calculate level based on actual indentation
const level = leadingSpaces.length === 0 ? 1 :
Math.floor(leadingSpaces.length / (leadingSpaces.length >= 4 ? 4 : 2)) + 1;
lists.push({
type: 'ordered',
level: level,
marker: orderedMatch[1],
indent: leadingSpaces.length
});
}
}
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;
let codeBlockStartLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 檢查行內程式碼 `code` (avoid matching inside fenced code blocks)
if (!inCodeBlock) {
const inlineCodeMatches = line.match(/`[^`\n]+`/g);
if (inlineCodeMatches) {
inlineCodeMatches.forEach(() => {
codeBlocks.push({
type: 'inline',
language: '',
content: '',
line: i + 1
});
});
}
}
// 檢查程式碼區塊 ``` or ~~~
const fenceMatch = line.trim().match(/^(```|~~~)(.*)$/);
if (fenceMatch) {
if (!inCodeBlock) {
// 開始程式碼區塊
const language = fenceMatch[2].trim();
currentBlock = {
type: 'block',
language: language,
content: '',
startLine: i + 1,
fence: fenceMatch[1]
};
inCodeBlock = true;
codeBlockStartLine = i;
} else if (currentBlock && fenceMatch[1] === currentBlock.fence) {
// 結束程式碼區塊 (matching fence type)
currentBlock.endLine = i + 1;
codeBlocks.push(currentBlock);
currentBlock = null;
inCodeBlock = false;
codeBlockStartLine = -1;
}
// If fence types don't match, treat as content
else if (inCodeBlock && currentBlock) {
currentBlock.content += line + '\n';
}
} else if (inCodeBlock && currentBlock) {
currentBlock.content += line + '\n';
}
// Check for indented code blocks (4+ spaces, not inside fenced blocks)
else if (!inCodeBlock && line.match(/^ /) && line.trim() !== '') {
// Ensure previous line is empty or also indented code
const prevLine = i > 0 ? lines[i - 1] : '';
// Don't treat list continuation lines as indented code blocks
if ((prevLine.trim() === '' || prevLine.match(/^ /)) && !isPartOfList(lines, i)) {
codeBlocks.push({
type: 'indented',
language: '',
content: line.substring(4),
line: i + 1
});
}
}
}
// Handle unclosed code blocks
if (inCodeBlock && currentBlock) {
currentBlock.endLine = lines.length;
currentBlock.unclosed = true;
codeBlocks.push(currentBlock);
}
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({
type: 'inline',
text: match[1],
url: match[2],
full: match[0]
});
}
// 參考式連結格式 [text][ref]
const refLinkRegex = /\[([^\]]*)\]\[([^\]]*)\]/g;
while ((match = refLinkRegex.exec(text)) !== null) {
links.push({
type: 'reference',
text: match[1],
ref: match[2] || match[1], // If ref is empty, use text as ref
full: match[0]
});
}
// 連結定義格式 [ref]: url "title"
const linkDefRegex = /^\s*\[([^\]]+)\]:\s*(\S+)(?:\s+"([^"]*)")?/gm;
while ((match = linkDefRegex.exec(text)) !== null) {
links.push({
type: 'definition',
ref: match[1],
url: match[2],
title: match[3] || '',
full: match[0]
});
}
// 自動連結格式 <url>
const autoLinkRegex = /<(https?:\/\/[^>]+)>/g;
while ((match = autoLinkRegex.exec(text)) !== null) {
links.push({
type: 'autolink',
text: match[1],
url: match[1],
full: match[0]
});
}
return links;
}
/**
* 生成詳細的特殊語法錯誤訊息
* @param {Array} originalSpecial - 原始特殊語法列表
* @param {Array} translatedSpecial - 翻譯後特殊語法列表
* @param {number} blockIndex - 區塊索引
* @returns {string} 詳細的錯誤訊息
*/
function generateDetailedSpecialSyntaxError(originalSpecial, translatedSpecial, blockIndex) {
const basicError = `區塊 ${blockIndex}: 特殊語法數量不一致 (原始: ${originalSpecial.length}, 翻譯: ${translatedSpecial.length})`;
const details = [];
if (originalSpecial.length > translatedSpecial.length) {
const missingCount = originalSpecial.length - translatedSpecial.length;
details.push(`缺失 ${missingCount} 個特殊語法`);
// 簡要列出缺失的語法類型
const originalTypes = originalSpecial.map(s => s.type);
const translatedTypes = translatedSpecial.map(s => s.type);
const missing = originalTypes.filter(type => !translatedTypes.includes(type));
if (missing.length > 0) {
details.push(`缺失類型: ${missing.join(', ')}`);
}
} else if (translatedSpecial.length > originalSpecial.length) {
const extraCount = translatedSpecial.length - originalSpecial.length;
details.push(`多出 ${extraCount} 個特殊語法`);
// 簡要列出多出的語法類型
const originalTypes = originalSpecial.map(s => s.type);
const translatedTypes = translatedSpecial.map(s => s.type);
const extra = translatedTypes.filter(type => !originalTypes.includes(type));
if (extra.length > 0) {
details.push(`多出類型: ${extra.join(', ')}`);
}
}
if (details.length > 0) {
return `${basicError} (${details.join('; ')})`;
}
return basicError;
}
/**
* 生成特殊語法差異的視覺化顯示
* @param {Array} originalSpecial - 原始特殊語法列表
* @param {Array} translatedSpecial - 翻譯後特殊語法列表
* @param {number} blockIndex - 區塊索引
* @returns {string} 格式化的差異顯示
*/
function generateSpecialSyntaxDifference(originalSpecial, translatedSpecial, blockIndex) {
const lines = [];
lines.push(`🔍 === 區塊 ${blockIndex} 特殊語法差異分析 ===`);
lines.push(`📊 數量比較: 原始 ${originalSpecial.length} 個 → 翻譯 ${translatedSpecial.length} 個`);
lines.push('');
// 顯示原始特殊語法
if (originalSpecial.length > 0) {
lines.push('✅ 原始文本中的特殊語法:');
originalSpecial.forEach((syntax, index) => {
const icon = getSyntaxIcon(syntax.syntax);
lines.push(` ${index + 1}. ${icon} ${syntax.syntax} → "${syntax.type}" (行 ${syntax.line})`);
if (syntax.content) {
lines.push(` 內容: "${syntax.content}"`);
}
});
} else {
lines.push('❌ 原始文本中沒有特殊語法');
}
lines.push('');
// 顯示翻譯後特殊語法
if (translatedSpecial.length > 0) {
lines.push('📝 翻譯文本中的特殊語法:');
translatedSpecial.forEach((syntax, index) => {
const icon = getSyntaxIcon(syntax.syntax);
lines.push(` ${index + 1}. ${icon} ${syntax.syntax} → "${syntax.type}" (行 ${syntax.line})`);
if (syntax.content) {
lines.push(` 內容: "${syntax.content}"`);
}
});
} else {
lines.push('❌ 翻譯文本中沒有特殊語法');
}
lines.push('');
// 分析差異
lines.push('🔄 差異分析:');
if (originalSpecial.length > translatedSpecial.length) {
const missingCount = originalSpecial.length - translatedSpecial.length;
lines.push(` ⚠️ 缺失了 ${missingCount} 個特殊語法`);
// 找出可能缺失的項目
const originalTypes = originalSpecial.map(s => `${s.syntax}:${s.type}`);
const translatedTypes = translatedSpecial.map(s => `${s.syntax}:${s.type}`);
const missing = originalTypes.filter(type => !translatedTypes.includes(type));
if (missing.length > 0) {
lines.push(' 🚫 可能缺失的特殊語法:');
missing.forEach(type => {
const [syntax, syntaxType] = type.split(':');
const icon = getSyntaxIcon(syntax);
lines.push(` • ${icon} ${syntax} → "${syntaxType}"`);
});
}
} else if (translatedSpecial.length > originalSpecial.length) {
const extraCount = translatedSpecial.length - originalSpecial.length;
lines.push(` ⚠️ 多了 ${extraCount} 個特殊語法`);
// 找出多出的項目
const originalTypes = originalSpecial.map(s => `${s.syntax}:${s.type}`);
const translatedTypes = translatedSpecial.map(s => `${s.syntax}:${s.type}`);
const extra = translatedTypes.filter(type => !originalTypes.includes(type));
if (extra.length > 0) {
lines.push(' ➕ 多出的特殊語法:');
extra.forEach(type => {
const [syntax, syntaxType] = type.split(':');
const icon = getSyntaxIcon(syntax);
lines.push(` • ${icon} ${syntax} → "${syntaxType}"`);
});
}
}
lines.push('🔚 === 特殊語法差異分析結束 ===');
lines.push('');
return lines.join('\n');
}
/**
* 根據語法類型獲取對應的圖標
* @param {string} syntaxType - 語法類型
* @returns {string} 對應的圖標
*/
function getSyntaxIcon(syntaxType) {
const icons = {
'vuepress-container': '📦',
'admonition': '💡',
'github-callout': '📢',
'frontmatter': '📋',
'math-block': '🧮',
'math-inline': '🔢',
'html-comment': '💬',
'table-row': '📊'
};
return icons[syntaxType] || '🔧';
}
/**
* 提取 Markdown 特殊語法(如 VuePress 的 ::: tip 等)
* @param {string} text - Markdown 文本
* @returns {Array} 特殊語法列表
*/
function extractMarkdownSpecialSyntax(text) {
const special = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// VuePress 容器語法 ::: type
const vuepressMatch = trimmed.match(/^:::\s*(\w+)(.*)$/);
if (vuepressMatch) {
special.push({
type: vuepressMatch[1],
syntax: 'vuepress-container',
content: vuepressMatch[2].trim(),
line: i + 1
});
continue;
}
// Admonition syntax (mkdocs, docusaurus) !!! type
const admonitionMatch = trimmed.match(/^!!!\s*(\w+)(.*)$/);
if (admonitionMatch) {
special.push({
type: admonitionMatch[1],
syntax: 'admonition',
content: admonitionMatch[2].trim(),
line: i + 1
});
continue;
}
// GitHub callouts > [!NOTE]
const calloutMatch = trimmed.match(/^>\s*\[!(\w+)\](.*)$/);
if (calloutMatch) {
special.push({
type: calloutMatch[1].toLowerCase(),
syntax: 'github-callout',
content: calloutMatch[2].trim(),
line: i + 1
});
continue;
}
// Front matter (YAML)
if (i === 0 && trimmed === '---') {
// Look for closing ---
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim() === '---') {
special.push({
type: 'yaml',
syntax: 'frontmatter',
startLine: i + 1,
endLine: j + 1,
content: lines.slice(i + 1, j).join('\n')
});
break;
}
}
continue;
}
// Math blocks $$
if (trimmed === '$$') {
special.push({
type: 'math',
syntax: 'math-block',
line: i + 1
});
continue;
}
// Inline math $...$
const inlineMathMatches = trimmed.match(/\$[^$\n]+\$/g);
if (inlineMathMatches) {
inlineMathMatches.forEach(() => {
special.push({
type: 'math',
syntax: 'math-inline',
line: i + 1
});
});
}
// HTML comments <!-- -->
const htmlCommentMatch = trimmed.match(/<!--[\s\S]*?-->/);
if (htmlCommentMatch) {
special.push({
type: 'comment',
syntax: 'html-comment',
line: i + 1
});
}
// Table rows (containing |)
if (trimmed.includes('|') && !trimmed.startsWith('```')) {
special.push({
type: 'table',
syntax: 'table-row',
line: i + 1
});
}
}
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;
}
// 逐行翻譯 Markdown 區塊,保留結構語法前綴
async function translateMarkdownBlockLineByLine(blockText, apiKey, model) {
const content = (blockText || '').replace(/\r\n/g, '\n');
const lines = content.split('\n');
let inCodeBlock = false;
let inFrontmatter = false;
let frontmatterStarted = false;
const items = [];
const isLinkRefLine = (line) => /^(\s*)\[[^\]]+\]:\s+\S/.test(line);
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const trimmed = raw.trim();
// Frontmatter --- ... ---
if (!frontmatterStarted && i === 0 && trimmed === '---') {
inFrontmatter = true;
frontmatterStarted = true;
items.push({ kind: 'literal', text: raw });
continue;
}
if (inFrontmatter) {
items.push({ kind: 'literal', text: raw });
if (trimmed === '---' && i !== 0) {
inFrontmatter = false;
}
continue;
}
// Code fences ``` or ~~~ (also allow inside blockquotes '>')
const stripped = r