├── LICENSE ├── README.md └── BilingualHTML.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 erpwibw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BilingualHTML 2 | 用 Python 和 ChatGPT API 实现的 HTML 网页翻译工具,可以将本地 HTML 网页批量翻译成中文双语对照。 3 | 4 | A HTML webpage translation tool implemented with Python and ChatGPT API, which can translate local HTML webpages in batch into bilingual versions in Chinese and English. 5 | 6 | --- 7 | ## 🌟用 chatGPT API 制作双语网页或电子书 8 | 9 | 10 | 参考 [Minja](https://twitter.com/Minja_Rin) 的[将外文电子书翻译成双语对照版本,并在任何设备上阅读](https://utgd.net/article/10001) 和 Github 上的 ChatGPT [bilingual_book_maker 11 | ](https://github.com/yihong0618/bilingual_book_maker) 项目,我实现了通过 Python 使用 chatGPT API 来制作双语网页/电子书。 12 | 13 | 14 | ### 💡主要功能: 15 | - 本地 HTML 文件批量制作为简体中文的双语对照格式(图1) 16 | ![image](https://user-images.githubusercontent.com/23517447/225020527-96fca4b7-5545-41c0-ac41-7adcd663fa9b.png) 17 | 18 | - 尽量保留原有的 HTML 格式(图2) 19 | 20 | ![image](https://user-images.githubusercontent.com/23517447/225020585-9f030fab-685a-45e9-ad74-090f9c943d3e.png) 21 | 22 | - 中断后可以继续翻译(图3) 23 | 24 | ![image](https://user-images.githubusercontent.com/23517447/225020640-07391e45-3e5c-43af-b408-f66cba86194d.png) 25 | 26 | - 显示翻译进度、花费时间和预计时间(图4) 27 | 28 | ![image](https://user-images.githubusercontent.com/23517447/225020706-60c53a3e-e05b-43a7-a74d-a2aeb5bde4f5.png) 29 | 30 | 结合 [Minja](https://twitter.com/Minja_Rin) 介绍的保存网页、拆 epub 电子书、在 Kindle 阅读网页等(见 [UNTAG 网站](https://utgd.net)),我们阅读的脚步再一次向前迈进🚀 31 | 32 | ### 🤔做这个脚本的原因是: 33 | - 双语电子书项目功能太复杂了 34 | - 市面上还没有 chatGPT 翻译网页 35 | - 参考 Minja 的方案网页/电子书翻译兼得 36 | - 学习使用 ChatGPT API 37 | 38 | 39 | ### ⬇️使用流程: 40 | 使用这个脚本需要电脑上可以运行 Python 脚本,并安装 BeautifulSoup 包,剩下的交给 ChatGPT。 41 | 42 | - 保存我编写的 Python 脚本到任意目录,例如 test 43 | - 在这个 test 目录下,创建 2 个文本文件 44 | - `api_key.txt` 里面保存你的 api_key(只输入 api_key 的内容即可) 45 | - `chatGPT_prompt.txt`里面保存你的 prompt(可以在我的基础上根据自己的需要微调) 46 | - 创建一个名为 `translatable` 的文件夹,将要翻译的 HTML 文件放进去(可以包含子文件夹,在子文件夹中放 HTML 文件,用来区分不同的项目一起制作) 47 | - 运行 Python 脚本,即可开始翻译并制作双语对照 HTML 48 | - 翻译结果会暂存在 `test/translated.json`。单个 HTML 文件翻译完成后,会创建一个 HTML文件名_cn.html 文件并将翻译结果一起写入。 49 | - 翻译进度会暂存在 `test/index.json`,用来记录翻译到哪一个文件。 50 | - 翻译时所有输出保存在 `test/log.txt` 文件中,作为调试的参考 51 | - 翻译中断时再次运行脚本即可。 52 | - 翻译结束后暂存文件都会删除(`log.txt`除外,不需要的话需手动删除)。 53 | 54 | 55 | ### 🤖**这是我使用的 prompt:** 56 | >Translate the HTML webpage into simplified Chinese, and return the translated HTML code. Translate in the style of Python programming books. Do not translate the text wrapped in `` tags. If the text cannot be translated, return the original text as is. Do not add any additional text to the translation. Do not translate person's name. The HTML webpage to be translated is: 57 | 58 | ### 待补充内容: 59 | - [ ] token 的使用情况 60 | - [ ] 时间花费情况 61 | - [ ] 工作原理 62 | 63 | -------------------------------------------------------------------------------- /BilingualHTML.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup as bs 2 | import openai 3 | import time 4 | import json 5 | import logging 6 | import os 7 | from pathlib import Path 8 | 9 | logging.basicConfig( 10 | level=logging.INFO, 11 | filename="./log.txt", 12 | filemode="a", 13 | format="%(asctime)s - %(levelname)s - %(funcName)s: %(message)s", 14 | ) 15 | 16 | 17 | def contains_only_code_tag(tag): 18 | """ 19 | 检查标记是否只包含一个代码标记。 20 | """ 21 | code_tag = tag.find('code') 22 | if code_tag is None: 23 | return False 24 | code_text = code_tag.text.replace('\n', '').replace(' ', '') 25 | p_text = tag.text.replace('\n', '').replace(' ', '') 26 | if code_text == p_text: 27 | return True 28 | 29 | 30 | def is_single_character_tag(tag): 31 | """ 32 | 判断一个标签内的文本是否只有一个字符 33 | 只有一个字符的话,就不需要翻译了 34 | """ 35 | text = tag.text.replace('\n', '').replace(' ', '') 36 | return len(text) == 1 37 | 38 | 39 | def is_jump_translate(tag): 40 | """ 41 | 判断是否可以跳过当前标签,不用翻译。 42 | """ 43 | return contains_only_code_tag(tag) or is_single_character_tag(tag) 44 | 45 | 46 | def get_translation(prompt, code): 47 | """ 48 | 将 HTML 网页翻译为简体中文,返回翻译后的 HTML 代码,包裹在 标签中的文本不会被翻译。 49 | 如果无法翻译,则返回原始的 HTML 代码。 50 | """ 51 | completion = openai.ChatCompletion.create( 52 | model="gpt-3.5-turbo", 53 | messages=[ 54 | {"role": "user", "content": f"{prompt} {code}"} 55 | ] 56 | ) 57 | return completion.choices[0].message.content 58 | 59 | 60 | def translate_tag(prompt, code): 61 | """ 62 | 将给定的 HTML 网页翻译为简体中文,如果翻译失败则进行重试。 63 | """ 64 | max_attempts = 5 65 | for i in range(max_attempts): 66 | try: 67 | t_text = get_translation(prompt, code) 68 | time.sleep(3) 69 | print(t_text) 70 | logging.info(t_text) 71 | return t_text 72 | except Exception as e: 73 | sleep_time = 60 74 | print(e) 75 | logging.error(e) 76 | print(f"请求失败,将等待 {sleep_time} 秒后重试") 77 | logging.info(f"请求失败,将等待 {sleep_time} 秒后重试") 78 | time.sleep(sleep_time) 79 | print(f"开始重试第 {i + 1}/{max_attempts}") 80 | logging.info(f"开始重试第 {i + 1}/{max_attempts}") 81 | print(f"请求失败,重试次数{max_attempts}/{max_attempts},放弃请求") 82 | logging.error(f"请求失败,重试次数{max_attempts}/{max_attempts},放弃请求") 83 | 84 | 85 | def read_api_key(path): 86 | with open(path, 'r') as f: 87 | api_key = f.read().strip() 88 | return api_key 89 | 90 | 91 | def read_chatGPT_prompt(path): 92 | with open(path, 'r') as f: 93 | prompt = f.read().strip() 94 | return prompt 95 | 96 | 97 | def read_page(path): 98 | """ 99 | 打开 HTML 网页,返回 BeautifulSoup 对象。 100 | """ 101 | with open(path, 'r') as f: 102 | soup = bs(f, "html.parser") 103 | return soup 104 | 105 | 106 | def read_json(path): 107 | """ 108 | 打开 JSON 文件,返回 JSON 对象。 109 | """ 110 | with open(path, 'r') as f: 111 | json_obj = json.load(f) 112 | return json_obj 113 | 114 | 115 | def write_json(path, code, mode='w'): 116 | """ 117 | 将 JSON 对象写入 JSON 文件。 118 | """ 119 | with open(path, mode) as f: 120 | json.dump(code, f, ensure_ascii=False) 121 | 122 | 123 | def write_page(path, soup): 124 | with open(path, 'w') as f: 125 | f.write(soup.prettify()) 126 | 127 | 128 | def resume_translate(): 129 | """ 130 | 恢复之前的翻译进度 131 | """ 132 | try: 133 | translated_texts = read_json('translated.json') 134 | start_index = len(translated_texts) 135 | print(f"从索引 {start_index + 1} 处继续翻译") 136 | logging.info(f"从索引 {start_index + 1} 处继续翻译") 137 | except: 138 | print("没有找到之前的翻译结果,从头开始翻译") 139 | logging.info("没有找到之前的翻译结果,从头开始翻译") 140 | start_index = 0 141 | translated_texts = [] 142 | return translated_texts, start_index 143 | 144 | 145 | def translate_page(start_time, page_num, page_count, page, prompt): 146 | """ 147 | 读取 HTML 页面并翻译其中的所有

标签内容为中文。 148 | 如果翻译结果已经存在,则从上次翻译的位置继续翻译。 149 | 翻译结果会保存在 'translated.json' 文件中。 150 | """ 151 | soup = read_page(page) 152 | p_list = soup.find_all('p') 153 | count = len(p_list) 154 | translated_texts, start_index = resume_translate() 155 | page_start_time = time.time() 156 | for i, p in enumerate(p_list[start_index:]): 157 | print("↓ " * 10 + "开始翻译 " + "↓ " * 10) 158 | logging.info("↓ " * 10 + "开始翻译 " + "↓ " * 10) 159 | p_start_time = time.time() 160 | p_code = p.prettify() 161 | print(p_code) 162 | logging.info(p_code) 163 | if p_code: 164 | if is_jump_translate(p): 165 | translated_texts.append(p_code) 166 | else: 167 | translated_texts.append(translate_tag(prompt, p_code)) 168 | write_json('translated.json', translated_texts) 169 | p_end_time = time.time() 170 | print(f"已经翻译 {i + start_index + 1}/{count}", end=',') 171 | logging.info(f"已经翻译 {i + start_index + 1}/{count}") 172 | p_elapsed_time = int(p_end_time - p_start_time) 173 | print(f"本段耗时 {p_elapsed_time} 秒", end=',') 174 | logging.info(f"本段耗时 {p_elapsed_time} 秒") 175 | every_p_time = int((p_end_time - page_start_time) / (i + start_index + 1)) 176 | print(f"平均每段耗时 {every_p_time} 秒") 177 | logging.info(f"平均每段耗时 {every_p_time} 秒") 178 | page_elapsed_time = int((p_end_time - page_start_time) / 60) 179 | print(f"本页耗时 {page_elapsed_time} 分钟", end=',') 180 | logging.info(f"本页耗时 {page_elapsed_time} 分钟") 181 | remaining_time = int(((count - i - start_index - 1) * every_p_time) / 60) 182 | print(f"本页预计剩余时间 {remaining_time} 分钟") 183 | logging.info(f"本页预计剩余时间 {remaining_time} 分钟") 184 | elapsed_time = int((p_end_time - start_time) / 60) 185 | print(f"总耗时 {elapsed_time} 分钟", end=',') 186 | logging.info(f"总耗时 {elapsed_time} 分钟") 187 | print(f"正在翻译第 {page_num + 1}/{page_count} 页") 188 | logging.info(f"正在翻译第 {page_num + 1}/{page_count} 页") 189 | print("↑ " * 10 + "翻译完成 " + "↑ " * 10) 190 | logging.info("↑ " * 10 + "翻译完成 " + "↑ " * 10) 191 | return count 192 | 193 | 194 | def save_translated_page(page, json): 195 | """ 196 | 在 HTML 文件中添加翻译后的 p 标签 197 | """ 198 | translated_texts = read_json(json) 199 | soup = read_page(page) 200 | p_list = soup.find_all('p') 201 | for i, p in enumerate(p_list): 202 | text = p.prettify() 203 | if text: 204 | translated_p = bs(translated_texts[i], 'html.parser') 205 | p.insert_after(translated_p) 206 | page_cn = page.parent / (page.stem + '_cn' + page.suffix) 207 | write_page(page_cn, soup) 208 | 209 | 210 | def get_translated_page(path): 211 | path = Path(path) 212 | return list(path.glob('**/*.html')) 213 | 214 | 215 | def resume_translate_page(): 216 | """ 217 | 恢复之前的翻译进度 218 | """ 219 | try: 220 | translated_index = read_json('index.json') 221 | start_index = len(translated_index) 222 | print(f"从索引 {start_index + 1} 处继续翻译") 223 | logging.info(f"从索引 {start_index + 1} 处继续翻译") 224 | except: 225 | print("没有找到之前的翻译索引,从第一个文件开始翻译") 226 | logging.info("没有找到之前的翻译索引,从第一个文件开始翻译") 227 | start_index = 0 228 | return start_index 229 | 230 | 231 | openai.api_key = read_api_key('api_key.txt') 232 | prompt = read_chatGPT_prompt('chatGPT_prompt.txt') 233 | path = Path('translatable') 234 | pages = get_translated_page('translatable') 235 | pages_count = len(list(pages)) 236 | start_time = time.time() 237 | start_index = resume_translate_page() 238 | for i, page in enumerate(pages[start_index:]): 239 | count = translate_page(start_time, i, pages_count, page, prompt) 240 | translated_texts = read_json('translated.json') 241 | if count == len(translated_texts): 242 | save_translated_page(page, 'translated.json') 243 | print(f"{page.stem} 翻译完成, 进度{i + 1}/{pages_count}") 244 | logging.info(f"{page.stem} 翻译完成, 进度{i + 1}/{pages_count}") 245 | os.remove('translated.json') 246 | write_json('index.json', str(page), 'a') 247 | os.remove('index.json') 248 | print("全部文件翻译完成") 249 | --------------------------------------------------------------------------------