11/**
22 * @file auto-translate.js
3- * @description Gemini API を使用して日本語ブログ記事を英語に自動翻訳するスクリプト
3+ * @description Gemini API または GitHub Models を使用して日本語ブログ記事を英語に自動翻訳するスクリプト
44 * @summary
55 * - content/blog 内の .md ファイルを読み込み、英語版 (.en.md) を生成
66 * - フロントマター(title, description, category, tags)と本文を翻訳
77 * - 既に英語版が存在する場合はスキップ
8- * @recent_changes
9- * - ANSIカラーコード出力を削除し、可読性を向上
10- * - 簡易ロガー関数を追加(本番環境では verbose ログを抑制)
8+ * - 環境変数 TRANSLATION_PROVIDER で 'gemini' または 'github' を切り替え可能
119 */
1210
1311const fs = require ( "fs" ) ;
@@ -20,29 +18,72 @@ require("dotenv").config();
2018// ───────────────────────────────────────────────────────────────
2119const isProduction = process . env . NODE_ENV === "production" ;
2220const logger = {
23- info : ( msg ) => ! isProduction && console . log ( `[INFO] ${ msg } ` ) ,
21+ info : ( msg ) =>
22+ ! isProduction && console . log ( `[INFO] ${ msg } ` ) ,
2423 warn : ( msg ) => console . warn ( `[WARN] ${ msg } ` ) ,
2524 error : ( msg ) => console . error ( `[ERROR] ${ msg } ` ) ,
2625 success : ( msg ) => console . log ( `[SUCCESS] ${ msg } ` ) ,
2726} ;
2827
29- // Configuration
30- const API_KEY = process . env . GEMINI_API_KEY ;
31- const MODEL =
32- process . env . GEMINI_MODEL || "gemini-2.0-flash" ; // 2.5-proは未リリースの可能性があるため、動作確認済みのモデルまたは環境変数に従う
33- const API_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${ MODEL } :generateContent?key=${ API_KEY } ` ;
28+ // ───────────────────────────────────────────────────────────────
29+ // Configuration & Validation
30+ // ───────────────────────────────────────────────────────────────
3431
35- const ROOT_DIR = path . join ( __dirname , ".." ) ;
36- const BLOG_DIR = path . join ( ROOT_DIR , "content" , "blog" ) ;
32+ // プロバイダーの決定: 環境変数指定 -> GeminiキーがあるならGemini -> GitHubキーがあるならGitHub
33+ let provider = process . env . TRANSLATION_PROVIDER ;
34+ if ( ! provider ) {
35+ if ( process . env . GEMINI_API_KEY ) {
36+ provider = "gemini" ;
37+ } else if ( process . env . GITHUB_TOKEN ) {
38+ provider = "github" ;
39+ }
40+ }
41+
42+ // Gemini Config
43+ const GEMINI_API_KEY = process . env . GEMINI_API_KEY ;
44+ const GEMINI_MODEL =
45+ process . env . GEMINI_MODEL || "gemini-2.0-flash" ;
46+ const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${ GEMINI_MODEL } :generateContent?key=${ GEMINI_API_KEY } ` ;
47+
48+ // GitHub Models Config
49+ const GITHUB_TOKEN = process . env . GITHUB_TOKEN ;
50+ const GITHUB_MODEL = process . env . GITHUB_MODEL || "gpt-4o" ;
51+ const GITHUB_ENDPOINT =
52+ "https://models.inference.ai.azure.com/chat/completions" ;
3753
38- // Validate API Key
39- if ( ! API_KEY ) {
40- logger . error ( "GEMINI_API_KEY is not set." ) ;
41- logger . error ( "Please set the GEMINI_API_KEY environment variable." ) ;
42- logger . error ( "Example: export GEMINI_API_KEY=AIza..." ) ;
54+ // Validate Configuration
55+ if ( provider === "gemini" ) {
56+ if ( ! GEMINI_API_KEY ) {
57+ logger . error ( "GEMINI_API_KEY is not set." ) ;
58+ process . exit ( 1 ) ;
59+ }
60+ logger . info ( `Using Provider: Gemini (${ GEMINI_MODEL } )` ) ;
61+ } else if ( provider === "github" ) {
62+ if ( ! GITHUB_TOKEN ) {
63+ logger . error ( "GITHUB_TOKEN is not set." ) ;
64+ logger . error (
65+ "Please set GITHUB_TOKEN to use GitHub Models." ,
66+ ) ;
67+ process . exit ( 1 ) ;
68+ }
69+ logger . info (
70+ `Using Provider: GitHub Models (${ GITHUB_MODEL } )` ,
71+ ) ;
72+ } else {
73+ logger . error ( "No valid translation provider found." ) ;
74+ logger . error (
75+ "Please set GEMINI_API_KEY or GITHUB_TOKEN in your .env file." ,
76+ ) ;
4377 process . exit ( 1 ) ;
4478}
4579
80+ const ROOT_DIR = path . join ( __dirname , ".." ) ;
81+ const BLOG_DIR = path . join ( ROOT_DIR , "content" , "blog" ) ;
82+
83+ // ───────────────────────────────────────────────────────────────
84+ // Translation Logic
85+ // ───────────────────────────────────────────────────────────────
86+
4687async function translateText ( text , context = "" ) {
4788 if ( ! text ) return "" ;
4889
@@ -54,6 +95,14 @@ Do not translate the file path in the image link.
5495${ context ? `Context: ${ context } ` : "" }
5596` ;
5697
98+ if ( provider === "gemini" ) {
99+ return await translateWithGemini ( text , systemPrompt ) ;
100+ } else {
101+ return await translateWithGitHub ( text , systemPrompt ) ;
102+ }
103+ }
104+
105+ async function translateWithGemini ( text , systemPrompt ) {
57106 const requestBody = {
58107 system_instruction : {
59108 parts : { text : systemPrompt } ,
@@ -64,7 +113,6 @@ ${context ? `Context: ${context}` : ""}
64113 parts : [ { text : text } ] ,
65114 } ,
66115 ] ,
67- // セーフティ設定を追加し、誤検知による停止を防ぐ
68116 safetySettings : [
69117 {
70118 category : "HARM_CATEGORY_HARASSMENT" ,
@@ -85,79 +133,118 @@ ${context ? `Context: ${context}` : ""}
85133 ] ,
86134 generationConfig : {
87135 temperature : 0.3 ,
88- maxOutputTokens : 8192 , // 長文での途中切れを防ぐため十分なトークン数を確保
136+ maxOutputTokens : 8192 ,
89137 } ,
90138 } ;
91139
92140 try {
93- const response = await fetch ( API_ENDPOINT , {
141+ const response = await fetch ( GEMINI_ENDPOINT , {
94142 method : "POST" ,
95- headers : {
96- "Content-Type" : "application/json" ,
97- } ,
143+ headers : { "Content-Type" : "application/json" } ,
98144 body : JSON . stringify ( requestBody ) ,
99145 } ) ;
100146
101147 if ( ! response . ok ) {
102148 const error = await response . json ( ) ;
103149 throw new Error (
104- `API Error: ${ response . status } - ${ JSON . stringify ( error ) } ` ,
150+ `Gemini API Error: ${ response . status } - ${ JSON . stringify ( error ) } ` ,
105151 ) ;
106152 }
107153
108154 const data = await response . json ( ) ;
109155
110156 if ( ! data . candidates || ! data . candidates [ 0 ] ) {
111- logger . warn ( `Unexpected response format: ${ JSON . stringify ( data ) } ` ) ;
112157 throw new Error (
113158 "Failed to parse Gemini response: No candidates found" ,
114159 ) ;
115160 }
116161
117162 const candidate = data . candidates [ 0 ] ;
118-
119- // 終了理由を確認し、正常終了でない場合は警告を出す
120163 if (
121164 candidate . finishReason &&
122165 candidate . finishReason !== "STOP"
123166 ) {
124- logger . warn ( `Translation stopped early. Reason: ${ candidate . finishReason } ` ) ;
125- // SAFETY等の理由でコンテンツが空の場合はエラーとする
126- if (
127- ! candidate . content ||
128- ! candidate . content . parts ||
129- ! candidate . content . parts [ 0 ] . text
130- ) {
167+ logger . warn (
168+ `Translation stopped early. Reason: ${ candidate . finishReason } ` ,
169+ ) ;
170+ if ( ! candidate . content ?. parts ?. [ 0 ] ?. text ) {
131171 throw new Error (
132172 `Generation blocked due to: ${ candidate . finishReason } ` ,
133173 ) ;
134174 }
135175 }
136176
177+ return candidate . content . parts [ 0 ] . text . trim ( ) ;
178+ } catch ( error ) {
179+ logger . error (
180+ `Gemini Translation failed: ${ error . message } ` ,
181+ ) ;
182+ throw error ;
183+ }
184+ }
185+
186+ async function translateWithGitHub ( text , systemPrompt ) {
187+ const requestBody = {
188+ model : GITHUB_MODEL ,
189+ messages : [
190+ { role : "system" , content : systemPrompt } ,
191+ { role : "user" , content : text } ,
192+ ] ,
193+ temperature : 0.3 ,
194+ max_tokens : 4096 ,
195+ } ;
196+
197+ try {
198+ const response = await fetch ( GITHUB_ENDPOINT , {
199+ method : "POST" ,
200+ headers : {
201+ "Content-Type" : "application/json" ,
202+ Authorization : `Bearer ${ GITHUB_TOKEN } ` ,
203+ } ,
204+ body : JSON . stringify ( requestBody ) ,
205+ } ) ;
206+
207+ if ( ! response . ok ) {
208+ const error = await response . json ( ) ;
209+ throw new Error (
210+ `GitHub API Error: ${ response . status } - ${ JSON . stringify ( error ) } ` ,
211+ ) ;
212+ }
213+
214+ const data = await response . json ( ) ;
215+
137216 if (
138- ! candidate . content ||
139- ! candidate . content . parts ||
140- ! candidate . content . parts [ 0 ] . text
217+ ! data . choices ||
218+ ! data . choices [ 0 ] ||
219+ ! data . choices [ 0 ] . message
141220 ) {
142221 throw new Error (
143- "Failed to parse Gemini response: No text content " ,
222+ "Failed to parse GitHub response: Invalid format " ,
144223 ) ;
145224 }
146225
147- return candidate . content . parts [ 0 ] . text . trim ( ) ;
226+ return data . choices [ 0 ] . message . content . trim ( ) ;
148227 } catch ( error ) {
149- logger . error ( `Translation failed: ${ error . message } ` ) ;
228+ logger . error (
229+ `GitHub Translation failed: ${ error . message } ` ,
230+ ) ;
150231 throw error ;
151232 }
152233}
153234
235+ // ───────────────────────────────────────────────────────────────
236+ // File Processing
237+ // ───────────────────────────────────────────────────────────────
238+
154239async function processFile ( filePath ) {
155240 const fileName = path . basename ( filePath ) ;
156241 const id = path . basename ( filePath , ".md" ) ;
157242 const enFilePath = path . join ( BLOG_DIR , `${ id } .en.md` ) ;
158243
159244 if ( fs . existsSync ( enFilePath ) ) {
160- logger . info ( `Skipping ${ fileName } : English version already exists.` ) ;
245+ logger . info (
246+ `Skipping ${ fileName } : English version already exists.` ,
247+ ) ;
161248 return ;
162249 }
163250
@@ -194,7 +281,6 @@ async function processFile(filePath) {
194281 let translatedTags = [ ] ;
195282 if ( Array . isArray ( frontmatter . tags ) ) {
196283 for ( const tag of frontmatter . tags ) {
197- // Skip translation for simple ASCII tags, translate others
198284 if (
199285 / [ \u3000 - \u303f \u3040 - \u309f \u30a0 - \u30ff \uff00 - \uff9f \u4e00 - \u9faf \u3400 - \u4dbf ] / . test (
200286 tag ,
@@ -233,7 +319,9 @@ async function processFile(filePath) {
233319 fs . writeFileSync ( enFilePath , newContent , "utf8" ) ;
234320 logger . success ( `Created ${ id } .en.md` ) ;
235321 } catch ( error ) {
236- logger . error ( `Failed to process ${ fileName } : ${ error . message } ` ) ;
322+ logger . error (
323+ `Failed to process ${ fileName } : ${ error . message } ` ,
324+ ) ;
237325 }
238326}
239327
0 commit comments