本文介绍了作者近期开发的一个集成了AI语音生成,大语言模型和一键部署程序的个人博客语音朗读插件生成工具。此工具可以一键部署,一键生成语音,自动插入朗读播放模块并一键执行博客编译和上传操作。


写在前面

众所周知 ,笔者本人是一名视力障碍人士,虽然没有到看不了屏幕的程度,但在阅读博客中的大段文本时,常常也会感到十分困难和不适。加上许多宝子也有听书的爱好,于是在打完比赛的间隙,着手开发了一个集成了AI语音生成,大语言模型和一键部署程序的个人博客语音朗读插件生成工具。

功能介绍

适用博客类型

本工具专为Hexo等静态网页博客系统打造,与Hexo Butterfly主题深度契合。在理论上,该工具也可移植到所有可以渲染Markdown格式文本的网页系统。

部署方式

首先,需要从开源地址获取工具链。 【戳我】
目前,该工具已经支持一键部署,请把下载的one-key-tools文件夹放在博客根目录之下。

API Key获取

使用阿里云语音合成服务和通义大模型均需要申请API密钥,你可以按照如下方法进行申请。

注册阿里云语音服务

阿里云语音服务合成速度快,且支持专门的长文本输入,无需对文本进行进一步分割等复杂操作,下面简述一下注册流程。
注册阿里云账户: 访问阿里云官网。点击右上角的“免费注册”,按照提示填写手机号或邮箱完成注册。

获取语音合成服务: 进入阿里云的功能面板,找到 智能语音交互2.0 服务组件,点击开通服务。此服务包含了一系列的语音识别和语音合成,我们找到“长文本语音合成”组件,这个组件目前只支持付费用户,但实际上点击“升级付费用户”并不会收取费用,而是提供了一定的免费额度。我们点击升级按钮,启用这个服务。

获取产品密钥和用户密钥: 打开语音服务的实例后,点击“查看使用文档”打开产品文档,根据指导获取密钥。一般来说,产品密钥APPKey在你控制台的产品列表中,用户的AccessKey密钥对在右上角头像的“权限与安全”一项获取。

完成操作后,你会得到三个密码,分别是AppKeyAK_IDAK_Secret。请存放在一个安全的文件中。

注册 通义API 账户 (通过阿里云平台)

通义千问是阿里云旗下的大模型服务,要使用其 API,你需要先注册一个阿里云账户,并进行实名认证。

注册阿里云账户: 访问阿里云官网。点击右上角的“免费注册”,按照提示填写手机号或邮箱完成注册。

完成实名认证: 为了使用大部分阿里云服务(包括大模型 API),你必须完成实名认证。登录账户后,导航到“控制台”或个人中心,根据指引完成个人或企业实名认证。这通常需要上传身份证信息。

开通通义千问服务: 在阿里云控制台中,搜索“大模型服务平台”或“通义千问”。进入服务页面,根据提示开通该服务。

获取 API Key: 在服务开通后,你可以在 API 管理或密钥管理页面找到你的 API Key。API Key 是访问 API 的凭证,请务必妥善保管,不要泄露

部署脚本执行

此时,你会获得阿里云的4个API密钥。打开一键配置工具文件夹的key.txt文件,分别将通义密钥、阿里云语音服务密钥和用户密钥按照文件指导存放在文件的前4行。

1
2
3
4
5
6
7
8
9
10
11
******
******
******
****** <以上为密码>
----------------------------------------------------------------------
# 说明:
# 按照以下顺序在三行中分别粘贴你的密钥:
# 1. 通义千问 API Key: 用于 gen_abstract.py 脚本来生成文章摘要。
# 2. 阿里云 AccessKey ID: 用于 gen_audio.py 脚本进行身份验证。
# 3. 阿里云 AccessKey Secret: 用于 gen_audio.py 脚本进行身份验证。
# 4. 阿里云 语音服务 Appkey: 用于 gen_audio.py 脚本指定语音合成应用。

双击打开setup.bat文件,系统将自动释放工程脚本,自动完成Python和必要工作环境的部署,并且自动修改主题配置文件_config.butterfly.yml以及根据你的API Key,自动将密钥填入脚本文件中。

文件概述和使用方法

解压后的工具总共包含以下文件:

  • gen_abstract.py:摘要生成程序
  • gen_audio.py:语音合成程序
  • gen_tag.py:文字处理程序,用于安放文本中播放器和摘要控件的位置
  • gen_site.shGit Bash脚本,用于统一执行上述脚本文件

在博客文件夹下打开Git Bash(一般而言,部署了Hexo和Node.js工具的一般都会涉及到Git的安装),只需要执行./gen_site.sh,即可对每篇文章生成自然的AI配音并自动加入朗读控件和配置表单,而后自动执行Hexo静态站点的清理和创建。
你还可以使用参数:

  • -s:将主机作为服务器,实行本地预览。
  • -d:执行生成后,自动执行默认的hexo deploy操作,博客推送到Github等网页托管服务器。

执行的效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
正在执行步骤 1/4:生成文章音频...
✅ Token 获取成功。

开始扫描并处理 Markdown 文件...
√ 音频文件 post-2024718.wav 已存在,跳过生成。
...
-> 语音合成排队中...请等待...
✅ 语音合成任务完成!
-> 正在下载音频文件...
✅ 成功保存最终音频文件:post-2025819.wav
...
正在执行步骤 2/4:生成文章摘要...
开始扫描文件夹 './source/_posts' 以更新标签...
-> 成功更新 post-2024718.md,已替换为 Meting-js HTML 标签。
...
所有文件处理完毕。总耗时: 0.10秒。
步骤 2/4 已完成:摘要文件生成完毕。
正在执行步骤 3/4:清理 Hexo 缓存...
INFO Validating config
...

效果

在执行生成之后,博文头部会出现一段精炼的摘要,以显示在博客首页,同时,头部会显示一个美观的播放器控件,可以流畅的朗迪文字,支持进度条控制。暂停重播、循环播放等功能。
PC端界面:
PC端
移动端界面:
移动端显示

第一版特性简介

笔者并没有在网络上找到完整实现这一功能并封装的实例,因此这一项目是基本自己探索开发的,作为一个可以在纯前端的Hexo网页上运行的实例,该项目拥有以下特性:

  • 部署方便,快速,只需要非常简单的网络知识
  • 在博客本身的JS/CSS层面改动较少,方便不同计算机之间的灵活转移
  • 使用批处理脚本,可以一键在未完成的博文上智能添加控件,效率高
  • 使用多线程和IO技术,在生成长文语音时支持自动分段和并行处理,大大降低了语音合成的时间开销
  • 使用CDN加载开源MetingJS控件,支持缓冲播放,界面美观,在网速较慢时仍可流畅播放

开发过程和技术分享

使用Aliyun SDK实现音频合成

首先,由于是纯前端页面,笔者一开始考虑的是通过在/themes/butterfly/source/js/themes/butterfly/layout下修改页面的pugcss/js文件,来显示表示播放和暂停的两个控件,且由于没有后端服务器,无法使用微软提供的SDK,因此也只能在网页端通过REST API,将网页内容实时发送到服务器,通过浏览器的fetch功能来解析链接,播放回传的mp3文件。
这一方法的最大缺点是需要完全加载后才能播放,速度慢,而且在遇到较长的文本时,需要处理非常长的时间甚至会因为微软API的最大长度限制而发送失败,体现为回传代码200,但是抛出Http2 ProtocolError错误,且配置十分复杂和麻烦,于是放弃。

接着,笔者尝试在本地部署时先生成音频文件,存储在/source/audio文件夹下,和博文同名,当脚本扫描到已经存在音频文件时,自动跳过生成。否则,文章会先被切片,去除代码块和会干扰到SSML解析的转义字符,上传到服务器生成.wav文件,最后将小的文件进行合并及写出。

一开始,笔者使用了微软Azure服务来生成语音,而且因为Azure对字数和时长都有限制,加上合成速度实在太慢,笔者加入了多线程技术,自动将文章分段并同步合成语音,最终通过BytesIO缓存和输出。但Azure的种种吃相还是太难看,而且我的账号今天到期了,然后就死活没法进行学术认证来续期,于是改成了阿里云API,相比之下,合成速度比Azure快了10倍,而且也没有了字数限制,在笔者最终的代码中,通过Http请求来发送文本、配置,读取排队状态和下载音频文件。

Python代码如下(可以通过代码框右侧按钮折叠):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# 此处存放阿里云的认证信息
ALIYUN_AK_ID = ''
ALIYUN_AK_SECRET = ''
ALIYUN_APPKEY = ''

# -*- coding: utf-8 -*-
import os
import re
import time
import json
import http.client
import urllib.request
import urllib.error
import sys
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import aliyunsdkcore.acs_exception as acs_exception

# 定义文件路径
# Define file paths
POSTS_DIR = 'source/_posts'
AUDIO_DIR = 'source/audio'
MAX_CHARS_PER_CHUNK = 1800

# 确保音频目录存在
# Ensure the audio directory exists
os.makedirs(AUDIO_DIR, exist_ok=True)

# --------------------------------------------------------------
# 阿里云 TTS API 相关类和函数
# Aliyun TTS API related classes and functions
# --------------------------------------------------------------

class TtsHeader:
def __init__(self, appkey, token):
self.appkey = appkey
self.token = token
def tojson(self, e):
return {'appkey': e.appkey, 'token': e.token}

class TtsContext:
def __init__(self, device_id):
self.device_id = device_id
def tojson(self, e):
return {'device_id': e.device_id}

class TtsRequest:
def __init__(self, voice, sample_rate, speed, format, enable_subtitle, text):
self.voice = voice
self.sample_rate = sample_rate
self.speed = speed
self.format = format
self.enable_subtitle = enable_subtitle
self.text = text
def tojson(self, e):
return {'voice': e.voice, 'sample_rate': e.sample_rate, 'speech_rate': e.speed, 'format': e.format, 'enable_subtitle': e.enable_subtitle, 'text': e.text}

class TtsPayload:
def __init__(self, enable_notify, notify_url, tts_request):
self.enable_notify = enable_notify
self.notify_url = notify_url
self.tts_request = tts_request
def tojson(self, e):
return {'enable_notify': e.enable_notify, 'notify_url': e.notify_url, 'tts_request': e.tts_request.tojson(e.tts_request)}

class TtsBody:
def __init__(self, tts_header, tts_context, tts_payload):
self.tts_header = tts_header
self.tts_context = tts_context
self.tts_payload = tts_payload
def tojson(self, e):
return {'header': e.tts_header.tojson(e.tts_header), 'context': e.tts_context.tojson(e.tts_context), 'payload': e.tts_payload.tojson(e.tts_payload)}

def get_aliyun_token():
"""
使用 AccessKey ID 和 AccessKey Secret 获取阿里云语音服务的 Token。
Uses AccessKey ID and AccessKey Secret to get a token for Aliyun Speech Service.
"""
if ALIYUN_AK_ID == 'your_ak_id' or ALIYUN_AK_SECRET == 'your_ak_secret':
print("错误:请先在脚本中填写你的 ALIYUN_AK_ID 和 ALIYUN_AK_SECRET。")
sys.exit(1)

try:
client = AcsClient(
ALIYUN_AK_ID,
ALIYUN_AK_SECRET,
"cn-shanghai"
)
request = CommonRequest()
request.set_method('POST')
request.set_domain('nls-meta.cn-shanghai.aliyuncs.com')
request.set_version('2019-02-28')
request.set_action_name('CreateToken')
response = client.do_action_with_exception(request)
jss = json.loads(response.decode('utf-8'))

if 'Token' in jss and 'Id' in jss['Token']:
token = jss['Token']['Id']
print("✅ Token 获取成功。")
return token
else:
print("获取 Token 失败:响应格式不正确。")
return None
except acs_exception.exceptions.ClientException as e:
print(f"获取 Token 失败:客户端异常,请检查你的 AK_ID 和 AK_SECRET。错误信息:{e}")
sys.exit(1)
except Exception as e:
print(f"获取 Token 失败:未知异常。错误信息:{e}")
sys.exit(1)

def wait_for_completion(appkey, token, task_id, request_id):
"""
轮询检查阿里云语音合成任务的状态,直到完成。
Polls Aliyun TTS task status until completion.
"""
host = 'nls-gateway-cn-shanghai.aliyuncs.com'
url = f'https://{host}/rest/v1/tts/async'
full_url = f"{url}?appkey={appkey}&task_id={task_id}&token={token}&request_id={request_id}"

print("-> 正在等待语音合成任务完成...")
while True:
try:
result = urllib.request.urlopen(full_url).read()
jsonData = json.loads(result)

if jsonData.get("data", {}).get("audio_address"):
print("✅ 语音合成任务完成!")
return jsonData["data"]["audio_address"]
elif "error_code" in jsonData and jsonData["error_code"] == 20000000 and "data" in jsonData:
print("-> 语音合成排队中...请等待...")
time.sleep(10)
else:
print("-> 语音合成进行中...")
time.sleep(10)
except urllib.error.URLError as e:
print(f"网络请求失败: {e.reason}")
return None
except Exception as e:
print(f"查询状态时发生未知错误: {e}")
return None

def synthesize_to_audio(appkey, token, text):
"""
使用阿里云长文本语音合成 API 生成音频。
Generates audio using Aliyun long-text TTS API.
"""
if not text:
return None

host = 'nls-gateway.cn-shanghai.aliyuncs.com'
url = f'https://{host}/rest/v1/tts/async'
http_headers = {'Content-Type': 'application/json'}

# 构造请求体
tr = TtsRequest("aiqian", 16000, 35, "wav", False, text)
tp = TtsPayload(False, "", tr) # 不使用回调,而是轮询
th = TtsHeader(appkey, token)
tc = TtsContext("mydevice")
tb = TtsBody(th, tc, tp)
body = json.dumps(tb, default=tb.tojson)

try:
conn = http.client.HTTPSConnection(host)
conn.request(method='POST', url=url, body=body.encode('utf-8'), headers=http_headers)
response = conn.getresponse()

if response.status == 200:
jsonData = json.loads(response.read().decode('utf-8'))
if jsonData['error_code'] == 20000000:
task_id = jsonData['data']['task_id']
request_id = jsonData['request_id']

# 轮询等待任务完成并获取音频URL
audio_url = wait_for_completion(appkey, token, task_id, request_id)
return audio_url
else:
print(f"x 语音合成请求失败: {jsonData['error_message']}")
return None
else:
print(f"x HTTP 请求失败: {response.status} {response.reason}")
print(f"响应内容: {response.read().decode('utf-8')}")
return None
except Exception as e:
print(f"x 语音合成请求时发生错误: {e}")
return None

# --------------------------------------------------------------
# Markdown 清理函数
# Markdown cleaning functions
# --------------------------------------------------------------

def clean_markdown_for_tts(markdown_text):
"""
清洗 Markdown 文本,为 TTS 准备,移除代码块、链接、图片等非文本元素。
Cleans Markdown text for TTS, removing code blocks, links, images, etc.
"""
# 移除代码块
cleaned_text = re.sub(r'```[\s\S]*?```', '', markdown_text)
# 移除行内代码
cleaned_text = re.sub(r'`([^`]+)`', r'\1', cleaned_text)
# 移除标题
cleaned_text = re.sub(r'^#+\s*', '', cleaned_text, flags=re.MULTILINE)
# 移除图片和链接
cleaned_text = re.sub(r'!*\[(.*?)\]\(.*?\)', r'\1', cleaned_text)
# 移除粗体
cleaned_text = re.sub(r'(\*\*|__)(.*?)\1', r'\2', cleaned_text)
# 移除斜体
cleaned_text = re.sub(r'(\*|_)(.*?)\1', r'\2', cleaned_text)
# 移除列表项
cleaned_text = re.sub(r'^\s*([*-]|\d+\.)\s+', '', cleaned_text, flags=re.MULTILINE)
# 移除引用块
cleaned_text = re.sub(r'^>\s*', '', cleaned_text, flags=re.MULTILINE)
# 移除分隔线
cleaned_text = re.sub(r'^(\s*[-*_]\s*){3,}\s*$', '', cleaned_text, flags=re.MULTILINE)
# 移除空行
cleaned_text = os.linesep.join([s for s in cleaned_text.splitlines() if s.strip()])
return cleaned_text

def remove_tag(content):
"""
接收全文内容,删除旧的meting HTML代码和meting Hexo控件代码,返回清理后的文本。
Receives full text content, removes old meting HTML and Hexo tags, and returns the cleaned text.
"""
# 匹配 aplayer 和 meting 标签的正则表达式模式,现在也包括了 meting-js HTML标签
# Regex pattern to match aplayer and meting tags, now also includes meting-js HTML tags
player_pattern = r'\{%\s*(aplayer|meting).*?%}\s*|

插入MetingJS播放器

在生成音频文件之后,就要将它们插入到文章里了。

最初,笔者使用了 Hexo 社区流行的 aplayermeting 标签来嵌入音频,这些标签语法简洁,形如 {% aplayer ... %}。然而,这种嵌入方式存在一些局限性,比如样式不够灵活,难以与现代博客主题完美融合。

在一次偶然的探索中,笔者发现了一种更加现代且功能强大的音频播放器解决方案:MetingJS 。它提供了一种灵活的 HTML 标签格式,允许笔者将播放器以卡片的形式嵌入到文章的任意位置,而不仅仅是作为独立的组件。这种方式不仅在视觉上更加美观,也让播放器与文章内容融为一体。【MetingJS开源链接】

既然有了新的目标,如何高效地将所有旧标签替换为新标签,便成为了一个需要解决的问题。如果手动逐一修改成百上千篇博文,那将是一项浩大的工程。因此,笔者决定编写一个自动化脚本来完成这项任务。

这个脚本的核心思路是:

  • 遍历所有文章 :脚本会递归地扫描博客的 source/_posts 文件夹,找出所有的 Markdown 文件。

  • 定位音频文件 :对于每一篇文章,脚本会根据其文件名推断出对应的 .wav 音频文件路径。

  • 智能判断与替换

首先,脚本会检查文章中是否已经存在一个正确的 MetingJS 标签。如果存在,那么它将跳过当前文件,不做任何改动,这大大提高了脚本的效率并避免了不必要的重复操作。

如果文章中没有正确的新标签,脚本就会执行替换操作。它会使用正则表达式,像一个外科医生般精确地移除所有旧的 aplayermeting 标签。

在清理工作完成后,脚本会在文章的more标签之后,或是在文章的 Front-matter 之后,插入全新的 MetingJS HTML 标签,并配置正确的音频文件路径和封面图片。

通过这个自动化脚本,笔者成功地将博客的音频播放器系统升级到了 MetingJS 格式,实现了高效且美观的博客有声化。

于是我编写了一个gen_tag.py脚本,用于逐个遍历音频文件,并在对应的文档上加入标签。脚本的代码如下(可以使用按钮进行折叠):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import re

# ===============================================
# 脚本名称:update_metingjs_tags.py
# 作用:移除所有Markdown文件中的旧播放器标签,并添加新的Meting-js HTML标签。
# ===============================================

# 文章文件夹的相对路径
# Relative path to the posts directory
POSTS_DIR = './source/_posts'
# 音频文件夹的相对路径,确保它与你的音频文件位置匹配
# Relative path to the audio directory, make sure it matches your audio file location
AUDIO_DIR = './source/audio'

# 请在这里填入你的封面图片URL
# Please fill in your cover image URL here
# Example: COVER_URL = "https://cdn.jsdelivr.net/gh/example/image.jpg"
COVER_URL = "https://snowmiku-blog-1326916956.cos.ap-hongkong.myqcloud.com/Screenshot_2024-08-15-00-35-26-521_com.tencent.mm.jpg?imageSlim"

def remove_and_add_meting_js_tag():
"""
遍历Markdown文件,移除旧的APlayer和Meting标签,并添加新的Meting-js HTML标签。
Iterates through Markdown files, removes old APlayer and Meting tags, and adds new Meting-js HTML tags.
"""
# 匹配 aplayer 和 meting 标签的正则表达式模式,现在也包括了 meting-js HTML标签
# Regex pattern to match aplayer and meting tags, now also includes meting-js HTML tags
player_pattern = r'\{%\s*(aplayer|meting).*?%}\s*|
'''
# 查找 <!-- more --> 标签
# Find the <!-- more --> tag
more_match = re.search(more_pattern, content)

if more_match:
# 如果找到了 <!-- more --> 标签,在其后插入 meting 标签
# If the <!-- more --> tag is found, insert the meting tag after it
more_tag_end = more_match.end()
new_content = content[:more_tag_end] + '\n\n' + meting_js_tag + content[more_tag_end:]
else:
# 如果没有找到 <!-- more --> 标签,则在 front-matter 之后插入
# If the <!-- more --> tag is not found, insert after the front-matter
match = front_matter_pattern.search(content)
if match:
front_matter = match.group(1)
body = content[match.end():]
new_content = front_matter + '\n\n' + meting_js_tag + body
else:
# 如果没有 Front-matter,则直接加到开头
# If there's no Front-matter, add it directly to the beginning
new_content = meting_js_tag + content

with open(md_path, 'w', encoding='utf-8') as f:
f.write(new_content)

print(f"-> 成功更新 {filename},已替换为 Meting-js HTML 标签。")

except Exception as e:
print(f"处理文件 {filename} 时出错: {e}")

if __name__ == "__main__":
if COVER_URL == "https://example.com/your-default-cover.jpg":
print("警告:请在脚本中设置 COVER_URL,否则无法正常工作!")
else:
remove_and_add_meting_js_tag()

脚本在生成音频之后执行,所有已经生成音频文件的文章,均会自动在front-matter字段之后插入播放器的html代码。

使用通义生成文章概要

这是一个附加功能,用于在播放器之前添加简短的概述,让界面更加美观,同时,这段摘要也会显示在博客主页的文章卡片之上,且通过<!-- more -->标注来与播放器代码和正文进行分割,避免在主页卡片中显示,影响美观度。此代码在生成播放器标签后执行,用来在播放器和头部之间插入文章概要。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# APIKey定义
APIKey = 'sk-******'
# -*- coding: utf-8 -*-
import os
import re
import time
from openai import OpenAI

# ====================
# 配置部分
# Configuration Section
# ====================
# 请将此路径替换为你的Hexo博客文章目录,通常是 'source/_posts'
# Please replace this with the path to your Hexo blog posts, usually 'source/_posts'
POSTS_DIR = 'source/_posts'
# 传递给大模型进行摘要生成的文章内容最大字符数
# Maximum number of characters for the summary to be sent to the LLM
SUMMARY_CHARS_LIMIT = 250

# 配置通义千问 API
# Configure Tongyi Qianwen API
try:
client = OpenAI(
# 如果没有配置环境变量,请用你的阿里云百炼API Key替换os.getenv()
# Replace with your actual Dashscope API Key if not set as an environment variable
api_key=APIKey,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
except Exception as e:
print(f"Failed to initialize Tongyi Qianwen client: {e}")
client = None

# 用于匹配和查找 '<!-- more -->' 标签的字符串
# String to match and find the '<!-- more -->' tag
MORE_TAG = '<!-- more -->'
# 用于匹配 Front-matter 块的正则表达式
# Regex to match the Front-matter block at the beginning of the file
FRONT_MATTER_PATTERN = re.compile(r'^(---[\s\S]*?---)\s*', re.MULTILINE)
# 用于匹配 Hexo 的 APlayer 标签的正则表达式
# Regex to match Hexo's APlayer tags
APLAYER_TAG_PATTERN = re.compile(r'\{\s*%\s*aplayer.*?%\s*\}', re.DOTALL)
# 用于匹配 Markdown 代码块的正则表达式
# Regex to match markdown code blocks
CODE_BLOCK_PATTERN = re.compile(r'```[\s\S]*?```', re.DOTALL)
# 新增:用于匹配 meting-js HTML 标签和 meting Hexo 标签的正则表达式
# New: Regex to match both meting-js HTML tags and meting Hexo tags
METING_PLAYER_PATTERN = re.compile(r'\{\s*%\s*meting.*?%\s*\}|<meting-js[\s\S]*?<\/meting-js>', re.DOTALL)

def clean_markdown_for_llm(markdown_text):
"""
清洗 Markdown 文本,为大模型处理做准备。
移除代码块、链接、图片和其他非文本元素。
"""
cleaned_text = CODE_BLOCK_PATTERN.sub('', markdown_text)
cleaned_text = re.sub(r'`([^`]+)`', r'\1', cleaned_text)
cleaned_text = re.sub(r'^#+\s*', '', cleaned_text, flags=re.MULTILINE)
cleaned_text = re.sub(r'!*\[(.*?)\]\(.*?\)', r'\1', cleaned_text)
cleaned_text = re.sub(r'(\*\*|__)(.*?)\1', r'\2', cleaned_text)
cleaned_text = re.sub(r'(\*|_)(.*?)\1', r'\2', cleaned_text)
cleaned_text = re.sub(r'^\s*([*-]|\d+\.)\s+', '', cleaned_text, flags=re.MULTILINE)
cleaned_text = re.sub(r'^>\s*', '', cleaned_text, flags=re.MULTILINE)
cleaned_text = re.sub(r'^(\s*[-*_]\s*){3,}\s*$', '', cleaned_text, flags=re.MULTILINE)
cleaned_text = os.linesep.join([s for s in cleaned_text.splitlines() if s.strip()])
return cleaned_text

def get_summary_from_llm(text):
"""
使用通义千问 API 生成文本摘要。
"""
if not client:
print("x 通义千问客户端未初始化。跳过摘要生成。")
return None

prompt = f"请根据以下文章内容,用中文生成一篇不超过100字的精炼摘要,以便于读者快速了解文章主旨。\n\n文章内容:\n{text}"

try:
completion = client.chat.completions.create(
model="qwen-plus",
messages=[
{'role': 'system', 'content': '你是一个善于提炼文章要点的助手,能够根据文章内容生成高质量的摘要。'},
{'role': 'user', 'content': prompt}
]
)
return completion.choices[0].message.content.strip()
except Exception as e:
print(f"x 调用通义千问API失败:{e}")
return None

def process_markdown_file(md_path):
"""
处理单个 Markdown 文件,插入摘要和 more 标签(如果缺失)。
"""
filename = os.path.basename(md_path)
print(f'开始处理:{filename}')

with open(md_path, 'r', encoding='utf-8') as f:
md_content = f.read()

# 1. 检查文章是否已经有 <!-- more --> 标签
if MORE_TAG in md_content:
print(f'√ {filename} 已经包含 "{MORE_TAG}" 标签,跳过。')
return

# 2. 找到 front-matter 和正文
match = FRONT_MATTER_PATTERN.search(md_content)
if not match:
print(f'x {filename} 格式异常,无法找到Front-matter。跳过。')
return

front_matter = match.group(1).strip()
front_matter_end_index = match.end()
body_with_player = md_content[front_matter_end_index:]

# 3. 找到 Meting 标签的位置,如果未找到则跳过
player_match = METING_PLAYER_PATTERN.search(body_with_player)
if not player_match:
print(f'x {filename} 未找到 Meting 标签,跳过摘要生成。')
return

# 4. 提取 Meting 标签**之后**的内容作为摘要源
text_after_player = body_with_player[player_match.end():].strip()
text_for_summary = clean_markdown_for_llm(text_after_player)
text_for_summary = text_for_summary[:SUMMARY_CHARS_LIMIT]

if not text_for_summary:
print(f'-> {filename} Meting 标签后正文为空,无法生成摘要。跳过。')
return

# 5. 调用大模型生成摘要
print(f'-> 正在调用大模型为 {filename} 生成摘要...')
generated_summary = get_summary_from_llm(text_for_summary)

if not generated_summary:
print(f'x {filename} 摘要生成失败,跳过。')
return

# 6. 重新构造文章内容
body_before_player = body_with_player[:player_match.start()]
player_tag = player_match.group(0)
body_after_player = body_with_player[player_match.end():]

new_content_body = f"{body_before_player}\n\n{generated_summary}\n\n{MORE_TAG}\n\n{player_tag}{body_after_player}"
final_content = f"{front_matter}\n\n{new_content_body.strip()}"

# 7. 写入修改后的文件
with open(md_path, 'w', encoding='utf-8') as f:
f.write(final_content)

print(f'✅ 已为 {filename} 成功插入摘要和 "{MORE_TAG}" 标签。')


def main():
"""
主函数,遍历文章目录并处理每个文件。
"""
print("开始扫描并处理 Markdown 文件...")
start_time = time.time()

if not os.path.isdir(POSTS_DIR):
print(f"错误: 博客文章目录 '{POSTS_DIR}' 不存在。请修改 POSTS_DIR 变量。")
return

for filename in os.listdir(POSTS_DIR):
if filename.endswith('.md'):
md_path = os.path.join(POSTS_DIR, filename)
process_markdown_file(md_path)
print('-' * 20)

end_time = time.time()
elapsed_time = end_time - start_time
print(f"所有文件处理完毕。总耗时: {elapsed_time:.2f}秒。")

if __name__ == "__main__":
# print("警告:此脚本将直接修改你的文件。强烈建议在运行前备份你的整个 Hexo 文件夹。\n")
# input("按 Enter 键开始执行...")
main()

执行这些操作之后,我使用gen-site.sh统领这些脚本文件,在Git Bash中执行,即可一键完成配音,修改和上传。

总结

葱酱是主打嵌入式和Linux开发的,其实不太懂前端,很多东西都是现搜或者问AI的,如有错误请多多指教!
这只是我的一点小项目,我希望自己在信息无障碍的路上可以走的更远,让每个人都有自由享受科技的权利!