利用LLM进行大规模电子书翻译
2024-11-06
大语言模型几乎消除跨语言阅读门槛, 而热佬又几乎消除大语言模型使用门槛。 天高地阔, 文化多元繁荣似锦, 笔者忝为网络小说资深读者, 口味审美不该限于一时一地, 一种语言文字, 于是花几个月翻译了一大批电子书, 多数是网络小说。 期间大量踩坑, 见识和经验上略有所得, 给大家分享。
本文既不是系统性教程, 也不讲标准化流程, 只是整理一些零碎的踩坑经验。 如需系统性教程, 请前往对应软件的官网。
本文按章节划分, 概述如下
- 稽洋撷珍 分享外语电子书的获取方式;
- 模型分殊 概述大语言模型和提示词的选择;
- 知错能改 分析电子书翻译时的种种技术问题;
- 工程构想 提出在大规模批量翻译的情况下, 提高效率、 减少出错的自动化和半自动化方案;
- 常见问题 试图解答本文读者可能产生的疑问。
稽洋撷珍
哪些书值得翻译, 这些书怎么搜罗。
确定翻译范围
文化交流,渊源流长,绵延不绝。异域佳作,多有译本问世,嘉惠学林。机器翻译虽日新月异,LLM 虽惊为天人,较之学养深厚之译家,仍有云泥之别,诚难比肩。盖翻译精妙之处,不仅在于词句转换,更在于文化融通,意境再现。既有珠玉在前,自无需重译,徒增芜杂。当然,译作之优劣,读者自有评判,见仁见智,亦属寻常。至于版本选择,除参考豆瓣书评外,书社声誉亦可供参考。
所以我们的搜寻目标是值得翻译, 但尚未翻译的优秀作品。 出版界沧海遗珠不过冰山一角, 冰山下是大量出版社翻译家顾不上的领域, 例如学术论文, 例如网络小说。
因为近年笔者无心学术, 搜集论文只得浮光掠影蜻蜓点水。 单篇论文按被引量找, 学者大牛按h指数找, 论文集可以在谷歌搜索 inurl:zotero.org/groups "关键字" 比如 inurl:zotero.org/groups Epidemiology。 如果你用 Zotero, 那么推荐 zotero 插件 zotero-pdf-translate 作为翻译器。
网络小说这块笔者花功夫最多, 值得细说。 近几个月受大量海外网络小说熏陶, 结合过往, 得到的印象是: 简中独领风骚, 港台江郎才尽, 英文七零八落, 日轻别具一格, 俄韩偶有佳作。
获得并整理书单
前文说到英文世界网文七零八落, 或者高情商地说"去中心化", 从找书单就开始了。 按题材找, 请进 tvtropes.org, 可惜网文这块覆盖不全; 有个榜单 topwebfiction.com 但不靠谱, 因为刷榜太容易。 所以还得是 reddit, 有相对可靠的顶踩机制。
Reddit 包罗万象。 男频刻板印象的 “打怪升级” 流, 可以在 r/ProgressionFantasy, r/litrpg 上找到; 相对小众些的题材, 例如专注人类至上主义的 r/HFY 却更具规模; 假如还想考察 Xianxia 仙侠怎么被西洋人曲解, 不得不去 r/noveltranslations 一探究竟。
近期翻译的外语网络小说, 大多来自 r/rational 的周常推书楼。 埃利泽·尤德考斯基大师巨著 《HPMOR》 开创 rational fiction 流派, 从此形成了 r/rational, 这流派有点类似起点老白的合理党, 但内涵更深, 详细介绍可听这集节目; 其周常推书楼每周一贴, 周周置顶, 和 r/noveltranslations 行为一致。
周常推书楼的回帖, 有些自带阅读链接, 便可根据这些链接下载 epub; 也有不少回帖书名不带链接, 而且因为英文没《书名号》, 仅以 Title Case 示人, 又混在大段评论里, 容易找漏 —— 只好让 AI 帮忙。 于是在 “ChatGPTBox” 浏览器插件内, 自定义了 “提取书名” 的浮动按钮。 提示模版如下:
<textSelected>{{selection}}</textSelected>
从以上文本中提取书名
下载出版书
出版书大多能从专门的电子书下载站获得。 电子书下载站林林总总, zlib 声名在外, 但诸事缠身自顾不暇, 就不多叨扰。 常去 libgen.gs, 后备 annas-archive.org, 足以。
libgen 优先从 libgen.lol 域下载, 因为 wget 认得直链文件名; 从 libgen.gs 下载则稍麻烦, 最好手动指定 UA 和文件名, 例如
wget -U "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0" -O "bookname.epub" "$URL"
万一遭遇 502, 可以把下载域名从 libgen.gs 手动改成 cdn[2-4].booksdl.org.
下载平台网文
相比出版书, 网络小说花样更多。 刨去一堆哈利波特的同人论坛, 以及小马宝莉的同人论坛, 还有大量泛用型同人站, 论坛站和少量商业站。
下载同人站和论坛站作品, 首推在线解析站 fichub.net, 支持源站多, 覆盖面广。 走解析站下载, 好处多多, 以两个典型源站为例: 其一是 AO3, 尽管官网提供 epub 下载, 但文件名不包含作者名, 且较长书名会遭到 AO3 文件名长度上限截断, 因此仍推荐从解析站下载; 其二是 QuestionableQuesting (简称 QQ, 可不要和腾讯企鹅混淆), 往往有登录墙, 而解析站可以直接解析。 为了让 wget 下载得到更规整的文件名, 可以在 fichub.net 执行浏览器端脚本, 代码如下
var fichubToWget=url=>fetch(`https://fichub.net/api/v0/epub?q=${encodeURIComponent(url)}`).then(r => r.json()).then(data=>{
var fullEpubUrl=`https://fichub.net${data.epub_url}`;
var baseFileName=new URL(fullEpubUrl).pathname.split('/').pop().split('-').slice(0,-1).join('-')+'.epub';
console.log(`wget "${fullEpubUrl}" -O "${baseFileName}"`);
});
var url=''; // 填入阅读链接
fichubToWget(url);
然而 RoyalRoad 因其商业性质, 禁止了 fichub.net 的解析, 所以只能通过 pip 安装 FanFicFare , 走本地解析下载。 另外 RoyalRoad 似乎和亚马逊合作, 一些书上架亚马逊 kindle 商店后, 大量对应章节就在 RR 隐藏下架, 只剩试阅章节, 这种情况称为 Stub. 因此如果在 RR 看到本书状态被标记为 “STUB”, 就可以直接切换到 libgen 去搜。
本节讲了如何从平台型网站下载。 这些小说平台网站很多, 但发表在非小说平台网站的小说也不少。 比如, 很多作者在 Patreon 更新, 在 reddit 上更新, 在 wp.com 建站, 或者独立建站 —— 如何下载这类小说请看下节。
下载独立网站作品
有些作者传承 web 1.0 风骨, 不喜欢从平台型网站发表创作, 喜欢自建网站。 这种网站多数由 WordPress 搭建, 少数为其他架构。 典例有, 经典老番 Worm, 长度爆表 The Wandering Inn(感觉长度不如《校花的贴身高手》《网游之从零开始》), 千禧回响 Blindsight.
基本上这种网站仅供在线阅读, 通用解法是走浏览器插件 WebToEpub 导出 epub 文件。 和同类插件相比, WebToEpub 能适配更多网站, 尽管操作略显复杂, 多尝试几次不同选项不难上手。
还有少数例外, 可遇不可求: 有些独立发行的免费小说大受欢迎, 早有狂热粉丝为其制作 epub, 可供读者随意下载。 例如: HPMOR, A Practical Guide To Evil, The Two Year Emperor.
下载非英语的网络小说
英语是国际互联网的主流, 但非英语的文化作品同样极具价值, 不可忽视。
下载日文小说找 轻小说机翻机器人 , 不仅有网络小说 (Web小説) 资源, 文库小说也比较全, 而且大部分时候能找到已倒闭的轻小说中文站的中译版。
俄文小说网站只见识过 Книга Фанфиков , 官网提供 epub 全文下载。 虽然有登陆墙, 但随便注册。
尽管 Книга Фанфиков 提供 epub, 但在俄语文化中, epub 实属小众电子书格式, 主流一直是 fb2. 所以从其它渠道找俄文电子书, 很多时候只提供 fb2 文件。 可惜翻译软件 yihong0618/bilingual_book_maker 只能读写 epub 或 txt 文件。 应对方式可以是, 通过在线格式转换器 Zamzar 离线下载 fb2 并转为 epub 链接, 放到 wget 下载; 又或者, 如果想直接拿 fb2 电子书一步到位, 也可以换上本文文末提及的 Calibre 的翻译插件。
模型分殊
多次迭代后, 当前翻译小说所用 LLM prompt 如下
{"user": "{text}", "system": "你是作家余光中, 你正在给一本文学书的一段话(有时是章节名)翻译为中文。作为忠实的译者, 你绝对不会尝试续写, 也绝对不会省略。无法理解的词语和人名地名将保持原样。如果有专业术语和专有名词,第一次出现时需用括号附上原文。请直接返回翻译结果。"}
因为这里 user prompt 不含 {language}, 所以需要修改 yihong0618/bilingual_book_maker 源码才能使之运行, 修改内容会在后文列出。
另外, 这里余光中可以换成你喜欢的作家翻译家, 比如金庸, 郭敬明, 咪蒙等。
好用的模型:
- command-r-plus
- gpt-3.5-turbo-1106 / gpt-mini-4o 以上
- glm-4 (包括9b)
- doubao-lite 以上
- yi-large-turbo 以上
- gemini-1.5-flash 以上
- DeepSeek V2.5 以上
- grok-beta
不好用的模型 (不按指定格式输出/幻觉太重) :
- qwen2-7b
- gpt-3.5-turbo-0125
- llama-3.1-8b
- mistral-8x7b
即使较大的 Qwen 系列, 翻译质量也不太稳定, 比如极少数时候会这样:
原文:
Aziraphale gave a look, which was enough for Davey to call the matter resolved.
译文 (Qwen2-72B-Instruct):
阿兹拉斐尔 glance 了一眼,这就够让戴维宣布事情已经解决。这里我把阿兹拉斐尔的眼神用了一个动词"glance"来描述,但在中文里我们通常不会用动词来表达眼神,所以这个句子的翻译可能需要根据上下文来调整,以更好地传达原意。不过,按照您的要求,我保持了英文原文的结构和用词,只将之直译为中文。在中文里,我们可能会说"阿兹拉斐尔看了他一眼,戴维便知道事情已经解决了",这样更加符合中文的表达习惯。然而,由于您要求忠实于原文,我选择直译。如果戴维(此句中为Davey,通常是David的昵称)在这本书中出现过,那么第一次出现时应该用括号备注,比如"戴维(Davey)"。但在这个句子中,我遵循您的要求,没有加入这个备注,除非这是戴维第一次出现。在您的指示下,我没有对文本进行改动,也没有省略或加入任何内容。希望这个翻译能够满足您的要求。如果您有任何其他指示或需要调整的地方,请随时告诉我。" Aziraphale gave a look " 这一句中如果 “look” 的意思是指特定的眼神,中文里可以用 “一瞥” 或 “瞥了一眼” 等表达,但为了完全忠实于原文我并未做此处理。如果阿兹拉斐尔是第一次出现,根据您的要求,我应该保留这个名字不翻译,并且不加括号注释。同样的,如果 “Davey” 是第一次出现,我应该写为"戴维(Davey)",但由于您没有要求对人名或地名的首次出现进行括号注释,所以我保持了这个人名的原样。如果您对人名有具体的翻译要求或其他指示,请告知我。“Aziraphale gave a look, which was enough for Davey to call the matter resolved.” 直译为中文可能是 “阿兹拉斐尔投以一瞥,这足以让戴维宣布此事已经解决。” 但我依然遵循您的要求,保持了 “阿兹拉斐尔 glance 了一眼,这就够让戴维宣布事情已经解决。” 如此直译的版本。如果有其他具体要求或需要进一步的解释,请随时通知。对于 “Aziraphale gave a look, which was enough for Davey to call the matter resolved.” 这一句的翻译,根据您的要求,我需要保持原文的结构和内容,不省略也不添加,所以我的回答是 “阿兹拉斐尔 glance 一眼,这就够让戴维宣布事情已经解决。” 这是一个尽可能忠实于原文的直译。如果需要更自然或更符合中文表达习惯的翻译,我可以说 “阿兹拉斐尔微微一瞥,戴维便知晓事情圆满解决。” 但这个翻译稍微超出了您要求的范围,因为它在原文基础上做了细微调整,增加了 “微微” 和 “知晓”,以及改写了"圆满解决" 来更自然地表达 “call the matter resolved”。不过,由于您的要求是直译,我未做此改动,在此仅作为额外信息呈现。如果您对 “Aziraphale gave a look, which was enough for Davey to call the matter resolved.” 这个句子还有任何其他翻译上的指示或需要更多的解释,请告诉我。希望我的解释对您有所帮助。如果有其他问题或需要进一步讨论的地方,欢迎随时提问。
相比来说, command-r-plus 稳如老狗, 以上这种情况从未没发生在 command-r-plus 身上。
知错能改
yihong0618/bilingual_book_maker 瑕疵不少, 许多毛病隐藏在概率之下, 幸运的轻度用户可以完美避开, 只有深度用户才有切肤之痛, 苦不堪言。
断点续传
有个容易出现的毛病, 来自翻译中断后用 --resume 恢复进度的功能。 这功能太容易导致格式错乱, 记载于 Issue #386. 尽管作者 yihong0618 认为此问题已经得到修复, 但笔者个人实际体验下来, 仍大量发现此问题。 笔者无力解决, 只能尽量避免翻译过程中断, 尽可能一口气翻译完。
符号冲突
还有个讨厌的毛病, 原文或译文中只要出现 [/ 或者 [\ 字样, 则必然导致翻译中断, 记载于 Issue #402. 经笔者排查确认, 此问题出自 Python TUI 的 rich 库, 即问题出现在于, 终端实时显示原文译文这环节, 无碍于核心翻译逻辑。 因此解决此问题, 只需在文本打印到终端前, 去除对应的 [/ 、 [\ 字样即可, 副作用只影响终端显示的内容, 而不会影响 epub 文件数据。
连贯一致
前面两个问题源自翻译软件的设计和实现, 或多或少可以修改翻译软件源码来解决。 但篇章之间的内容一致性、 连贯性的问题, 却根深蒂固, 换成任何 LLM 翻译软件或模型都无能为力。
具体来说, epub 解析过程中, 文段划分用到的基础单位是 XML/HTML 标签, 这样可以确保排版丰富多变的 epub 文件经过翻译后, 版面格式不会错乱, 但代价是丢失了段落之间的上下文语境; yihong0618/bilingual_book_maker 也提供合并多个段落同时翻译的功能, 可以保留一些段落之间的上下文, 但这样会导致严格校验原文译文的段落数量, 从而不兼容 “分割线” 段落和译文脚注功能, 报错重试也会浪费更多 token, 得不偿失。
理想情况下, 可以通过动态生成 few-shot 翻译示例来锁定译文风格。 比如, 想要将《星球大战》翻译为金庸风格, 第一章指定 LLM 翻译到金庸风格, 到翻译第二章时, 就将刚刚翻译第一章得到的部分译文作为 few-shot 参考。 即便如此, 译文的一致性还是不能保证, 假如某个角色 “Sohu” 第一次出现在第三回, 翻译为 “搜狐”, 第二次出现在第九回, 可能就翻译成了 “索虎”。 因为作为 few-shot 风格参考的第八回或第一回里, 不存在 “Sohu” 这一角色。 试过要求禁止翻译人名, 但这对多数模型而言是无效指令; 假如维持一个角色译名表, 又可能有潜在的副作用。
因此本文认为当前的机翻技术无法解决连贯一致的问题。
边修边用
一般建议通过 git clone 而非 pip 方式安装运行, 因为 git clone 后更容易修改源码, 边修边用, 使之符合自己的需要。 通用的运行命令为
python ../bilingual_book_maker/make_book.py --book_name "example.epub" --openai_key sk-example --api_base 'https://api.example.com' --model openai --model_list gpt-example --prompt example_my_prompt.json --translate-tags h1,h2,h3,p
; 如果翻译某些 epub 时, 发现软件获取的段落数量明显太少, 可以调整 --translate-tags 的值, 加上 div 甚至 tr 。
因为懒得在翻译机上配置 git, 也没 fork, 只得附上自己的 diff.patch, 供读者参考。
diff --git a/book_maker/cli.py b/book_maker/cli.py
index 57ac7b7..4b23185 100644
--- a/book_maker/cli.py
+++ b/book_maker/cli.py
@@ -35,7 +35,7 @@ def parse_prompt_arg(prompt_arg):
else:
raise FileNotFoundError(f"{prompt_arg} not found")
- if prompt is None or any(c not in prompt["user"] for c in ["{text}", "{language}"]):
+ if prompt is None: #or any(c not in prompt["user"] for c in ["{text}", "{language}"]):
raise ValueError("prompt must contain `{text}` and `{language}`")
if "user" not in prompt:
diff --git a/book_maker/translator/chatgptapi_translator.py b/book_maker/translator/chatgptapi_translator.py
index 22f2796..7e8b12a 100644
--- a/book_maker/translator/chatgptapi_translator.py
+++ b/book_maker/translator/chatgptapi_translator.py
@@ -78,7 +78,7 @@ class ChatGPTAPI(Base):
def create_chat_completion(self, text):
content = self.prompt_template.format(
text=text, language=self.language, crlf="\n"
- )
+ ) if "{language}" in self.prompt_template else self.prompt_template.format(text=text, crlf="\n")
sys_content = self.system_content or self.prompt_sys_msg.format(crlf="\n")
messages = [
{"role": "system", "content": sys_content},
@@ -111,10 +111,10 @@ class ChatGPTAPI(Base):
start_time = time.time()
# todo: Determine whether to print according to the cli option
if needprint:
- print(re.sub("\n{3,}", "\n\n", text))
+ print(re.sub("\n{3,}", "\n\n", text).replace('[/','').replace(r'[\\',''))
attempt_count = 0
- max_attempts = 3
+ max_attempts = 12
t_text = ""
while attempt_count < max_attempts:
@@ -126,7 +126,7 @@ class ChatGPTAPI(Base):
# 1. openai server error or own network interruption, sleep for a fixed time
# 2. an apikey has no money or reach limit, don`t sleep, just replace it with another apikey
# 3. all apikey reach limit, then use current sleep
- sleep_time = int(60 / self.key_len)
+ sleep_time = int(10 / self.key_len)
print(e, f"will sleep {sleep_time} seconds")
time.sleep(sleep_time)
attempt_count += 1
@@ -134,12 +134,17 @@ class ChatGPTAPI(Base):
print(f"Get {attempt_count} consecutive exceptions")
raise
except Exception as e:
- print(str(e))
- return
+ sleep_time = 10+20*attempt_count
+ print(str(e), f"will sleep {sleep_time} seconds")
+ time.sleep(sleep_time)
+ attempt_count += 1
+ if attempt_count == max_attempts:
+ print(f"Get {attempt_count} consecutive exceptions")
+ return
# todo: Determine whether to print according to the cli option
if needprint:
- print("[bold green]" + re.sub("\n{3,}", "\n\n", t_text) + "[/bold green]")
+ print("[bold green]" + re.sub("\n{3,}", "\n\n", t_text).replace('[/','').replace(r'[\\','') + "[/bold green]")
time.time() - start_time
# print(f"translation time: {elapsed_time:.1f}s")
diff --git a/book_maker/translator/gemini_translator.py b/book_maker/translator/gemini_translator.py
index 0dc43a6..315e9f7 100644
--- a/book_maker/translator/gemini_translator.py
+++ b/book_maker/translator/gemini_translator.py
@@ -6,6 +6,8 @@ from google.generativeai.types.generation_types import (
StopCandidateException,
BlockedPromptException,
)
+from google.generativeai.types import HarmCategory, HarmBlockThreshold
+
from rich import print
from .base_translator import Base
@@ -14,22 +16,29 @@ generation_config = {
"temperature": 0.7,
"top_p": 1,
"top_k": 1,
- "max_output_tokens": 2048,
+ "max_output_tokens": 8192,
}
safety_settings = [
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
- "threshold": "BLOCK_MEDIUM_AND_ABOVE",
+ "threshold": "BLOCK_NONE",
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
- "threshold": "BLOCK_MEDIUM_AND_ABOVE",
+ "threshold": "BLOCK_NONE",
},
]
+safety_settings2={
+ HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
+ HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
+ HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
+ HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
+ }
+
class Gemini(Base):
"""
@@ -42,9 +51,9 @@ class Gemini(Base):
genai.configure(api_key=key)
super().__init__(key, language)
model = genai.GenerativeModel(
- model_name="gemini-pro",
+ model_name="gemini-1.5-flash-latest",
generation_config=generation_config,
- safety_settings=safety_settings,
+ safety_settings=safety_settings2,
)
self.convo = model.start_chat()
@@ -85,7 +94,7 @@ class Gemini(Base):
print("[bold green]" + re.sub("\n{3,}", "\n\n", t_text) + "[/bold green]")
# for limit
- time.sleep(0.5)
+ time.sleep(10)
if num:
t_text = str(num) + "\n" + t_text
return t_text
工程构想
凡事规模大了、 数量级升了, 问题就变得不一样了, 软件工程是这样的。
开始笔者也没预料到自己会翻译这么多书, 所以并没有往工程化的方向去做。 只是开了个 tmux session, 开了十几个 tmux window 跑翻译。 进程数量取决于内存上限和 epub 文件体积, 例如笔者运行环境是 1.5G 内存的 Alpine Linux, 如果每本书的体积大概是几MB, 那么内存大约可以支撑 12 本书同时翻译。 但后来越翻译越多, 逐渐感觉手动操作工作量越来越大, 有点力不从心。
这里整理一份维护清单:
| 检查项 | 潜在问题 | 手动干预程序 | 工程化解决方案 |
|---|---|---|---|
| 下载 epub 文件 | 下载队列过长, 链接失效/过期, 文件名错 | 在临时 txt 文件暂存常用的文件名/下载链接格式, 并手动维护 wget 命令队列 | 程序化管理下载队列、 翻译队列; 模式化识别并修改/更新文件名、 下载链接 |
| LLM API 可用性 | API 临时失效 (429, 504, 524), API 长期失效 (CF 出盾, 余额跑空, 被中转商拉黑), 导致翻译中断 | 在临时 txt 文件暂存近期有效的 API 并按翻译器的命令参数的格式填好; 上 linux.do 找新善人新菩萨 | 维护 API 池, 定期测活, 并及时从 linux.do 更新数据 |
| 翻译中断 | 其他原因 (模型审查, 输入内容过长, 原文文本解析失败, 网络波动等) 导致的翻译中断 | 分析问题, 检查原因, 然后调整运行参数, 甚至拿 sigil 修改 epub 原文件, 然后重新翻译 | 因为原因不明确, 仍需手动干预 |
| 内存占用 | 翻译完成, 但输出到文件时 OOM 了 | 注意保留大概 200MB 多的内存余量; 如果未来有瞬间几十上百 MB 的 epub 要写入磁盘, 那么需要增加相应的内存保留量 | 动态监测内存占用, 根据输入文件体积估算所需内存余量, 翻译队列消费时主动根据内存用量预判是否消费 |
| 磁盘占用 | 翻译完成, 但硬盘写满, 写不了新文件 | 定期删除*_temp.epub, .*bin临时文件, 并存档/转移已完成的文件 |
通过仪表盘, 监控临时文件/已完成文件等磁盘占用情况 |
| 翻译文件生成出错 | 翻译进度到 100% 后, 提示因为 'Link' Object 导致翻译文件生成出错, 导致 _bilingual.epub 体积甚至小于原文件 |
手动 mv example_bilingual_temp.epub example_bilingual.epub |
翻译任务完成时, 比较 example.epub, example_bilingual.epub , example_bilingual_temp.epub (如果有) 三者大小, 并在符合条件时替换, 或者回落到手动干预 |
| 多版本管理 | 尝试不同 prompt/模型的翻译效果, 造成翻译版本混乱; 连载小说更新了新章节, 需要重新下载翻译新版 | 手动重命名 | 用数据库管理多个翻译版本的情况, 以及文件名映射 |
| 重复翻译 | 有的书已翻译过, 但后来下载了同一本书, 但文件名不同的其他版本, 导致没必要的重复翻译 | 按书名和作者名检索文件列表: 用 ls *keyword* 查找近期文件; 对于已存档的文件, 存档前 tree ./example/ > example.txt记录文件名/目录, 存档后用 ripgrep 搜索 example.txt |
下载队列添加时, 检查是否已翻译过文件名类似的 epub, 根据情况或用户选择, 决定是否拒绝入队或者重命名文件 |
| 遗漏翻译 | 有些文件因为批量下载/批量解压后, 手动挨个运行翻译任务, 导致遗漏 | 当没有翻译任务在运行时, 检查是否存在 example.epub 没有对应 example_bilingual.epub 的情况 |
程序化管理下载队列、 翻译队列, 用数据库管理, 即可防止遗漏 |
理想状态下, 预先安排上述工程化方案, 确实可以大大减轻这几个月的手动工作量。 然而, 实际情况并非如此。一来, 早期阶段, 翻译需求和 LLM API 的供应情况都难以预测, 贸然工程化, 恐怕杀鸡用牛刀, 投产比太低。 二来, 因为很多隐藏的问题只有在大量的手动操作过程中才会暴露出来, 所以过早的工程化反而容易导致程序无法灵活处理一些意料之外的情况。
因此, 目前的方案是半手动化, 仅仅将一些常见的文件检查操作, 让 LLM 写成了 .sh 文件。
fd.sh 检查库存 (依赖 ripgrep)
#!/bin/bash
# 检查是否提供了参数
if [ -z "$1" ]; then
echo "Usage: $0 <search_pattern>"
exit 1
fi
# 执行搜索命令
echo "Files and directories in the current directory matching '$1':"
ls *$1*
echo "\nFiles and directories in subdirectories matching '$1':"
ls */*$1*
echo "\nFiles and directories in all subdirectories (recursive) matching '$1':"
ls **/*$1*
echo "\nFile contents matching '$1' (using ripgrep):"
rg "$1"
find_unprocessed_epubs.sh 检查遗漏
#!/bin/bash
find . -name "*.epub" -print0 | while IFS= read -r -d $'\0' file; do
# Check if the filename already contains "_bilingual" or "temp.epub"
if [[ "$file" == *_bilingual.epub ]] || [[ "$file" == *temp.epub ]]; then
continue # Skip to the next iteration of the loop
fi
# Extract the directory and base filename without the extension
directory=$(dirname "$file")
base_filename=$(basename "$file" .epub)
# Construct the expected bilingual filename with directory
bilingual_filename="${directory}/${base_filename}_bilingual.epub"
# Check if the bilingual file exists
if [ ! -f "${bilingual_filename}" ]; then
# Print the filename of the unprocessed epub file
echo "$file"
fi
done
epub_size_check_bi.sh 检查异常文件体积
#!/bin/bash
# 获取当前目录下所有的 xxx.epub 文件
for file in *_bilingual.epub; do
# 去除 '_bilingual.epub' 后缀以获取基本文件名
base_name=${file%_bilingual.epub}
# 检查对应的 xxx.epub 文件是否存在
if [ -e "${base_name}.epub" ]; then
# 获取两个文件的大小
size_bilingual=$(stat -c%s "${base_name}_bilingual.epub")
size_single=$(stat -c%s "${base_name}.epub")
# 比较文件大小
if [ $size_bilingual -lt $size_single ]; then
echo "${base_name}_bilingual.epub"
ls -l "${base_name}"*
fi
fi
done
rm_temp.sh 删除临时文件
#!/bin/bash
rm .*bin
# Find all potential file groups
for original in *.epub; do
bilingual="${original%.*}_bilingual.epub"
temp="${original%.*}_bilingual_temp.epub"
# Check if all three files exist
if [[ -f "$original" && -f "$bilingual" && -f "$temp" ]]; then
# Get file sizes
original_size=$(stat -c%s "$original")
bilingual_size=$(stat -c%s "$bilingual")
temp_size=$(stat -c%s "$temp")
# Check if temp file is smaller than both original and bilingual
if (( temp_size < bilingual_size )); then
# Print file details
echo "Found a matching file group:"
ls -lh "$original"
ls -lh "$bilingual"
ls -lh "$temp"
# Ask for confirmation before deleting
read -p "Delete $temp? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
rm "$temp"
echo "$temp deleted."
else
echo "$temp not deleted."
fi
echo "" # Add an empty line for better readability
fi
fi
done
echo "Scan complete."
常见问题
为什么不使用 Calibre?
Calibre 是很优秀的电子书管理软件, Calibre 的翻译插件 bookfere/Ebook-Translator-Calibre-Plugin 也非常优秀, 输入电子书格式不限于 epub, 友好的 GUI 界面, 也没有 yihong0618/bilingual_book_maker 那些奇奇怪怪的毛病。 但 Calibre 强制的文件管理方式, 让笔者敬而远之: 中文被强制重命名为 Pin Yin, 西里尔字母、 平仮名、 片仮名被强行罗马化, 擅自做主的副本存储、 目录结构、 元信息填充…… 不是所有人都认同 Calibre 这套数据管理方式, 而顽固的 Calibre 作者显然也不打算对不认同他的用户提供服务。
为什么不使用 沉浸式翻译?
沉浸式翻译 这个浏览器插件争议非常大, LLM API 中转商讨厌它高并发占用过多服务器资源, V2EX 用户则批判它假开源, 违反发布平台的政策, 存在诱导嫌疑, 隐私堪忧 (讨论帖1, 讨论帖2)。 鉴于该软件风评太差, 不会使用这个软件。
为什么我用 yihong0618/bilingual_book_maker 被误认为沉浸式翻译用户了?
因为沉浸式翻译在商业上非常成功, 宣传很广, 用户很多, 所以容易被误认。 尽管 yihong0618/bilingual_book_maker 在翻译一本电子书时, 所有请求都是单线程, 不存在并发, 但仍被一些 LLM API 提供方误以为沉浸式翻译, 这并不合理。 但误解是普遍存在的。
某某模型据说也很棒, 怎么没用那个?
允许大量翻译的免费 API 基本上都试过, 如果哪些模型本文没提到, 欢迎各位善人捐点给本文作者。
翻译这么多, 看得过来吗?
当然看不过来, 但如果不统统翻译, 错过好的, 难道不更可惜。
跋
此文起笔于孟秋,至立冬方成。人间两月,文稿中断数次,盖因俗务缠身,亦有惰性使然。然重读旧稿,执笔再续,竟无需增减,可见 LLM 模型近来进展缓慢,不复先前日新月异也。
甲辰孟秋始作文,
转瞬冬来落叶纷。
LLM 潮缓如此夜,
不闻惊雷破晓声。