1+ import OpenAI from "openai" ;
2+ import fs from "fs-extra" ;
3+ import path from "path" ;
4+
5+ /**
6+ * 配置区
7+ */
8+ const SRC_DIR = "en/changelog" ;
9+ const TARGET_LANGS = [
10+ {
11+ code : "cn" ,
12+ name : "Chinese" ,
13+ systemPrompt :
14+ "请将以下英文 changelog 按中文语境重写一下,要求:1. 只翻译纯文本部分,忽略任何 HTML 标签、代码块、表格、特殊格式(如代码行、列)等,看着像代码也保留不动。2. 保留原有 HTML 标签和结构,不要修改格式。3. 保证翻译内容准确。4.小标题的单词也要翻译(标题的日期不要翻译)。5.不要直译,要理解英文原文的语义,然后用符合中文语言习惯的自然方式重新表述。例如:'action' 不应直译为'行动',而应根据上下文用更自然的中文表达。确保翻译后的中文读起来自然流畅,符合中文表达习惯。" ,
15+ } ,
16+ {
17+ code : "ko" ,
18+ name : "Korean" ,
19+ systemPrompt :
20+ "다음 영어 changelog 를 한국어 문맥에 맞게 재작성해 주세요. 다음 요구사항을 엄격히 준수하세요: 1. 텍스트 내용만 번역하고, HTML 태그, 코드 블록, 표, 특수 형식(예: 코드 행, 열 등) 등은 무시하고, 코드로 보이는 모든 내용은 그대로 유지하세요. 2. 원본 HTML 태그와 구조를 유지하고, 형식을 수정하지 마세요. 3. 번역 내용의 정확성을 보장하세요. 4. 소제목의 단어도 반드시 번역하세요(소제목의 날짜는 번역하지 마세요). 5. 직역하지 말고, 영어 원문의 의미를 이해한 후 한국어 언어 습관에 맞는 자연스러운 방식으로 재표현하세요. 예를 들어, 'action'을 단순히 '행동'으로 직역하지 말고, 문맥에 따라 더 자연스러운 한국어 표현을 사용하세요. 번역된 한국어가 자연스럽고 유창하게 읽히도록 한국어 표현 습관에 맞게 작성하세요." ,
21+ } ,
22+ ] ;
23+
24+ // 初始化客户端
25+ const client = new OpenAI ( {
26+ apiKey : process . env . OPENAI_API_KEY ,
27+ timeout : 120000 ,
28+ maxRetries : 0 ,
29+ } ) ;
30+
31+ /**
32+ * 重试策略
33+ */
34+ async function withRetry ( fn , maxRetries = 5 ) {
35+ let retries = 0 ;
36+ while ( retries < maxRetries ) {
37+ try {
38+ return await fn ( ) ;
39+ } catch ( err ) {
40+ retries ++ ;
41+ if ( retries >= maxRetries ) {
42+ throw new Error ( `重试${ maxRetries } 次后仍失败:${ err . message } ` ) ;
43+ }
44+ const delay = 1000 * Math . pow ( 2 , retries ) ;
45+ console . log ( `请求失败,${ delay } ms 后重试(第 ${ retries } /${ maxRetries } 次):` , err . message ) ;
46+ await new Promise ( resolve => setTimeout ( resolve , delay ) ) ;
47+ }
48+ }
49+ }
50+
51+ /**
52+ * 分块函数(仅处理待翻译部分)
53+ */
54+ function splitTextByParagraphs ( text , maxChars = 8000 ) {
55+ const paragraphs = text . split ( "\n\n" ) ;
56+ const chunks = [ ] ;
57+ let currentChunk = "" ;
58+
59+ for ( const para of paragraphs ) {
60+ if ( para . length > maxChars ) {
61+ const subPara = para . split ( "\n" ) ;
62+ let subCurrent = "" ;
63+ for ( const sub of subPara ) {
64+ if ( subCurrent . length + sub . length <= maxChars ) {
65+ subCurrent += sub + "\n" ;
66+ } else {
67+ chunks . push ( subCurrent . trim ( ) ) ;
68+ subCurrent = sub + "\n" ;
69+ }
70+ }
71+ if ( subCurrent . trim ( ) ) chunks . push ( subCurrent . trim ( ) ) ;
72+ continue ;
73+ }
74+
75+ if ( currentChunk . length + para . length <= maxChars ) {
76+ currentChunk += para + "\n\n" ;
77+ } else {
78+ chunks . push ( currentChunk . trim ( ) ) ;
79+ currentChunk = para + "\n\n" ;
80+ }
81+ }
82+ if ( currentChunk . trim ( ) ) {
83+ chunks . push ( currentChunk . trim ( ) ) ;
84+ }
85+ console . log ( `✅ 待翻译部分拆分为 ${ chunks . length } 块,单块最大${ maxChars } 字符` ) ;
86+ return chunks ;
87+ }
88+
89+ /**
90+ * 🔥 双标记截断逻辑(核心修改)
91+ * 规则:
92+ * 1. 前标记(markerBefore)及之前 → 不翻译,保留
93+ * 2. 前标记后 ~ 后标记前 → 翻译
94+ * 3. 后标记(markerAfter)及之后 → 不翻译,保留
95+ */
96+ function truncateWithTwoMarkers ( text , markerBefore , markerAfter ) {
97+ // 1. 定位前标记(兼容跨多行)
98+ const markerBeforeIndex = text . indexOf ( markerBefore ) ;
99+ // 2. 定位后标记(从前往后找,且在前标记之后)
100+ const markerAfterIndex = markerBeforeIndex === - 1
101+ ? - 1
102+ : text . indexOf ( markerAfter , markerBeforeIndex + markerBefore . length ) ;
103+
104+ // 边界情况1:没找到前标记 → 只处理后标记(后标记及之后不翻译)
105+ if ( markerBeforeIndex === - 1 ) {
106+ if ( markerAfterIndex === - 1 ) {
107+ console . log ( "⚠️ 未找到任何标记,将翻译全部内容" ) ;
108+ return { translatePart : text , keepBefore : "" , keepAfter : "" } ;
109+ }
110+ console . log ( "⚠️ 未找到前标记,仅保留后标记及之后不翻译" ) ;
111+ return {
112+ translatePart : text . slice ( 0 , markerAfterIndex ) . trim ( ) ,
113+ keepBefore : "" ,
114+ keepAfter : text . slice ( markerAfterIndex )
115+ } ;
116+ }
117+
118+ // 边界情况2:找到前标记,但没找到后标记 → 仅前标记及之前不翻译,之后全翻译
119+ if ( markerAfterIndex === - 1 ) {
120+ console . log ( "⚠️ 未找到后标记,仅保留前标记及之前不翻译" ) ;
121+ return {
122+ translatePart : text . slice ( markerBeforeIndex + markerBefore . length ) . trim ( ) ,
123+ keepBefore : text . slice ( 0 , markerBeforeIndex + markerBefore . length ) ,
124+ keepAfter : ""
125+ } ;
126+ }
127+
128+ // 正常情况:前后标记都找到 → 中间部分翻译
129+ console . log ( `✅ 双标记定位成功:
130+ - 前标记位置:${ markerBeforeIndex }
131+ - 后标记位置:${ markerAfterIndex } ` ) ;
132+
133+ return {
134+ // 待翻译:前标记后 ~ 后标记前
135+ translatePart : text . slice ( markerBeforeIndex + markerBefore . length , markerAfterIndex ) . trim ( ) ,
136+ // 保留:前标记及之前
137+ keepBefore : text . slice ( 0 , markerBeforeIndex + markerBefore . length ) ,
138+ // 保留:后标记及之后
139+ keepAfter : text . slice ( markerAfterIndex )
140+ } ;
141+ }
142+
143+ /**
144+ * 翻译函数(整合双标记+分块+翻译+拼接)
145+ */
146+ async function translate ( text , systemPrompt ) {
147+ console . log ( "\n📝 原始文本总长度:" , text . length , "字符" ) ;
148+
149+ // 🔥 配置两个标记(原样复制,含换行/缩进/特殊字符)
150+ // 前标记:}; return <ShowResult />; })() }</div>
151+ const markerBefore = `};
152+ return <ShowResult />;
153+ })()}
154+ </div>` ;
155+ // 后标记:{/* Component definitions - moved to end of file for cleaner code organization */}
156+ const markerAfter = `{/* Component definitions - moved to end of file for cleaner code organization */}` ;
157+
158+ // 执行双标记截断
159+ const { translatePart, keepBefore, keepAfter } = truncateWithTwoMarkers ( text , markerBefore , markerAfter ) ;
160+
161+ // 无待翻译内容 → 直接返回保留的前后部分
162+ if ( ! translatePart ) {
163+ return keepBefore + keepAfter ;
164+ }
165+
166+ // 分块翻译中间内容
167+ const chunks = splitTextByParagraphs ( translatePart ) ;
168+ const translatedChunks = [ ] ;
169+
170+ for ( let i = 0 ; i < chunks . length ; i ++ ) {
171+ console . log ( `🔄 翻译第 ${ i + 1 } /${ chunks . length } 块(字符数:${ chunks [ i ] . length } )` ) ;
172+ const res = await withRetry ( async ( ) => {
173+ return await client . chat . completions . create ( {
174+ model : "gpt-4o-mini" ,
175+ messages : [
176+ { role : "system" , content : systemPrompt } ,
177+ { role : "user" , content : `请翻译以下文本,严格遵循系统指令:\n${ chunks [ i ] } ` } ,
178+ ] ,
179+ temperature : 0.0 ,
180+ max_tokens : 4096 ,
181+ stream : false ,
182+ } ) ;
183+ } ) ;
184+
185+ if ( ! res || ! res . choices || res . choices . length === 0 ) {
186+ throw new Error ( `第${ i + 1 } 块翻译失败:API返回异常` ) ;
187+ }
188+ translatedChunks . push ( res . choices [ 0 ] . message . content . trim ( ) ) ;
189+ }
190+
191+ // 拼接最终结果:前保留 + 翻译后的中间内容 + 后保留
192+ const translatedPart = translatedChunks . join ( "\n\n" ) ;
193+ const finalResult = keepBefore + ( translatedPart ? "\n" + translatedPart : "" ) + keepAfter ;
194+
195+ return finalResult ;
196+ }
197+
198+ /**
199+ * 主流程
200+ */
201+ async function run ( ) {
202+ if ( ! ( await fs . pathExists ( SRC_DIR ) ) ) {
203+ console . log ( "❌ 未找到 changelog 目录,跳过翻译" ) ;
204+ return ;
205+ }
206+
207+ const files = await fs . readdir ( SRC_DIR ) ;
208+ for ( const file of files ) {
209+ if ( ! file . endsWith ( ".md" ) && ! file . endsWith ( ".mdx" ) ) continue ;
210+
211+ const srcPath = path . join ( SRC_DIR , file ) ;
212+ const content = await fs . readFile ( srcPath , "utf-8" ) ;
213+
214+ console . log ( `\n========== 开始处理 ${ srcPath } ==========` ) ;
215+
216+ for ( const lang of TARGET_LANGS ) {
217+ const outDir = path . join ( lang . code , "changelog" ) ;
218+ const outPath = path . join ( outDir , file ) ;
219+ await fs . ensureDir ( outDir ) ;
220+
221+ try {
222+ const translated = await translate ( content , lang . systemPrompt ) ;
223+ await fs . writeFile ( outPath , translated , "utf-8" ) ;
224+ console . log ( `✅ 成功:${ file } → ${ lang . code } /changelog/${ file } ` ) ;
225+ } catch ( err ) {
226+ console . error ( `❌ 失败:${ file } → ${ lang . code } ` , err . stack ) ;
227+ continue ;
228+ }
229+ }
230+ }
231+
232+ console . log ( "\n🎉 所有文件处理完成!" ) ;
233+ }
234+
235+ // 执行主流程
236+ run ( ) . catch ( ( err ) => {
237+ console . error ( "💥 全局执行失败:" , err . stack ) ;
238+ process . exit ( 1 ) ;
239+ } ) ;
0 commit comments