Skip to content

Commit b526de7

Browse files
authored
feat(tools): add batch translation support with parallel processing (#1099)
Enhance the translation tool to support directory-based batch translation with configurable concurrency control. Changes: - Add glob pattern support for file matching - Add -c/--concurrency option to control parallel processing (default: 2) - Add --force option to re-translate already translated files - Implement p-limit for parallel execution control - Improve error handling with temp file logging - Skip translated files by default (.en.md existence check) - Display comprehensive summary with success/failure counts Usage examples: pnpm run translate -w "adev-ja/src/content/guide/*.md" pnpm run translate -w -c 5 "adev-ja/src/content/**/*.md" pnpm run translate -w --force "adev-ja/src/content/guide/*.md"
1 parent a5b6558 commit b526de7

File tree

3 files changed

+144
-31
lines changed

3 files changed

+144
-31
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"execa": "^9.3.0",
2929
"globby": "14.0.2",
3030
"langchain": "0.3.28",
31+
"p-limit": "^7.2.0",
3132
"prh": "5.4.4",
3233
"rxjs": "7.8.1",
3334
"sitemap": "8.0.0",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/translator/main.ts

Lines changed: 126 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import consola from 'consola';
22
import assert from 'node:assert';
33
import { readFile, writeFile } from 'node:fs/promises';
44
import { parseArgs } from 'node:util';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
57
import { cli } from 'textlint';
8+
import { globby } from 'globby';
9+
import pLimit from 'p-limit';
610
import {
711
cpRf,
812
exists,
@@ -15,8 +19,10 @@ import { MarkdownTranslator } from './translate';
1519
* CLI引数の型定義
1620
*/
1721
interface CliArgs {
18-
file: string;
22+
pattern: string;
1923
write?: boolean;
24+
concurrency?: number;
25+
force?: boolean;
2026
help?: boolean;
2127
}
2228

@@ -38,24 +44,28 @@ function validateEnvironment(): { googleApiKey: string; geminiModel?: string } {
3844
*/
3945
function showHelp(): void {
4046
console.log(`
41-
使用方法: npx tsx tools/translator/main.ts [オプション] <ファイルパス>
47+
使用方法: npx tsx tools/translator/main.ts [オプション] <パターン>
4248
4349
Markdownファイルを日本語に翻訳します。
4450
4551
オプション:
46-
-w, --write 確認なしで翻訳結果を保存
47-
-h, --help このヘルプメッセージを表示
52+
-w, --write 確認なしで翻訳結果を保存
53+
-c, --concurrency <n> 並列処理数(デフォルト: 2)
54+
--force 翻訳済みファイルを再翻訳
55+
-h, --help このヘルプメッセージを表示
4856
4957
引数:
50-
<ファイルパス> 翻訳するMarkdownファイルのパス
58+
<パターン> 翻訳するMarkdownファイルのパスまたはglobパターン
5159
5260
環境変数:
5361
GOOGLE_API_KEY Google AI API キー(必須)
5462
GEMINI_MODEL 使用するGeminiモデル(オプション)
5563
5664
例:
5765
npx tsx tools/translator/main.ts example.md
58-
npx tsx tools/translator/main.ts -w example.md
66+
npx tsx tools/translator/main.ts -w "adev-ja/src/content/guide/*.md"
67+
npx tsx tools/translator/main.ts -w -c 5 "adev-ja/src/content/**/*.md"
68+
npx tsx tools/translator/main.ts -w --force "adev-ja/src/content/guide/*.md"
5969
`);
6070
}
6171

@@ -66,35 +76,59 @@ function parseCliArgs(): CliArgs {
6676
const args = parseArgs({
6777
options: {
6878
write: { type: 'boolean', default: false, short: 'w' },
79+
concurrency: { type: 'string', default: '2', short: 'c' },
80+
force: { type: 'boolean', default: false },
6981
help: { type: 'boolean', default: false, short: 'h' },
7082
},
7183
allowPositionals: true,
7284
});
7385

74-
const { write, help } = args.values;
75-
const [file] = args.positionals;
86+
const { write, help, force } = args.values;
87+
const concurrency = parseInt(args.values.concurrency || '2', 10);
88+
const [pattern] = args.positionals;
7689

7790
if (help) {
7891
showHelp();
7992
process.exit(0);
8093
}
8194

82-
if (!file) {
95+
if (!pattern) {
8396
showHelp();
84-
throw new Error('ファイルパスを指定してください。');
97+
throw new Error('ファイルパスまたはglobパターンを指定してください。');
8598
}
8699

87-
return { write, file, help };
100+
if (isNaN(concurrency) || concurrency < 1) {
101+
throw new Error('並列数は1以上の整数で指定してください。');
102+
}
103+
104+
return { write, pattern, concurrency, force, help };
88105
}
89106

90107
/**
91-
* ファイルの存在確認
108+
* glob パターンからファイルリストを収集
92109
*/
93-
async function validateFileExistence(file: string): Promise<void> {
94-
const fileExists = await exists(file);
95-
if (!fileExists) {
96-
throw new Error(`ファイルが見つかりません: ${file}`);
110+
async function collectFiles(pattern: string, force: boolean): Promise<string[]> {
111+
const files = await globby(pattern, {
112+
ignore: ['**/*.en.md', '**/*.en.ts', '**/*.en.json'],
113+
});
114+
115+
if (files.length === 0) {
116+
throw new Error(`パターンに一致するファイルが見つかりません: ${pattern}`);
117+
}
118+
119+
// force が false の場合、翻訳済みファイルをフィルタリング
120+
if (!force) {
121+
const untranslatedFiles: string[] = [];
122+
for (const file of files) {
123+
const enFile = getEnFilePath(file);
124+
if (!(await exists(enFile))) {
125+
untranslatedFiles.push(file);
126+
}
127+
}
128+
return untranslatedFiles;
97129
}
130+
131+
return files;
98132
}
99133

100134
/**
@@ -197,31 +231,92 @@ async function runTextlint(file: string): Promise<void> {
197231
}
198232
}
199233

234+
/**
235+
* 単一ファイルの翻訳処理(エラーハンドリング含む)
236+
*/
237+
async function processSingleFile(
238+
file: string,
239+
googleApiKey: string,
240+
geminiModel: string | undefined,
241+
forceWrite: boolean
242+
): Promise<{ file: string; success: boolean; error?: Error }> {
243+
try {
244+
consola.start(`翻訳開始: ${file}`);
245+
246+
const translated = await translateFile(file, googleApiKey, geminiModel);
247+
const savedFile = await saveTranslation(file, translated, forceWrite);
248+
249+
if (!savedFile) {
250+
return { file, success: false };
251+
}
252+
253+
// 翻訳結果の分析
254+
await validateLineCount(getEnFilePath(savedFile), savedFile);
255+
await runTextlint(savedFile);
256+
257+
consola.success(`翻訳完了: ${file}`);
258+
return { file, success: true };
259+
} catch (error) {
260+
consola.warn(`翻訳失敗: ${file}`);
261+
return { file, success: false, error: error as Error };
262+
}
263+
}
264+
200265
/**
201266
* アプリケーションのメインエントリーポイント
202267
*/
203268
async function main() {
204-
const { write, file } = parseCliArgs();
269+
const { write, pattern, concurrency, force } = parseCliArgs();
205270
const { googleApiKey, geminiModel } = validateEnvironment();
206271

207-
await validateFileExistence(file);
208-
209-
consola.start(`Starting translation for ${file}`);
210-
211-
const translated = await translateFile(file, googleApiKey, geminiModel);
272+
// ファイルリスト収集
273+
const files = await collectFiles(pattern, !!force);
212274

213-
console.log(translated);
214-
const savedFile = await saveTranslation(file, translated, !!write);
215-
if (!savedFile) {
275+
if (files.length === 0) {
276+
consola.warn('翻訳対象のファイルがありません。');
216277
return;
217278
}
218279

219-
// 翻訳結果の分析
220-
consola.start(`翻訳結果を分析...`);
221-
// 原文ファイルとの行数比較
222-
await validateLineCount(getEnFilePath(savedFile), savedFile);
223-
// textlintの実行
224-
await runTextlint(savedFile);
280+
consola.info(`翻訳対象: ${files.length}件 (並列数: ${concurrency})`);
281+
282+
// 並列処理制御
283+
const limit = pLimit(concurrency!);
284+
const results = await Promise.all(
285+
files.map((file) =>
286+
limit(() => processSingleFile(file, googleApiKey, geminiModel, !!write))
287+
)
288+
);
289+
290+
// 最終サマリー表示
291+
const succeeded = results.filter((r) => r.success);
292+
const failed = results.filter((r) => !r.success);
293+
294+
// エラー詳細を一時ファイルに保存
295+
let errorLogPath: string | null = null;
296+
if (failed.length > 0) {
297+
errorLogPath = join(tmpdir(), `translate-errors-${Date.now()}.log`);
298+
const errorDetails = failed
299+
.map((r) => {
300+
const errorStack = r.error?.stack || r.error?.message || 'Unknown error';
301+
return `\n${'='.repeat(80)}\nファイル: ${r.file}\n${'='.repeat(80)}\n${errorStack}\n`;
302+
})
303+
.join('\n');
304+
305+
await writeFile(errorLogPath, errorDetails, 'utf-8');
306+
}
307+
308+
consola.box(`
309+
翻訳完了
310+
311+
成功: ${succeeded.length}
312+
失敗: ${failed.length}
313+
${failed.length > 0 ? '\n失敗したファイル:\n' + failed.map((r) => ` - ${r.file}`).join('\n') : ''}
314+
${errorLogPath ? `\nエラー詳細: ${errorLogPath}` : ''}
315+
`);
316+
317+
if (failed.length > 0) {
318+
process.exit(1);
319+
}
225320
}
226321

227322
main().catch((error) => {

0 commit comments

Comments
 (0)