Files
fj/server.py
T
selfreleaseandDevin bc0bfd9263 feat: 新增5部经典下拉选经、TTS重试、PWA可安装
- 新增阿弥陀经/普门品/道德经/清静经/太上感应篇(维基文库底本,OpenCC转简体)
- 经典切换改为下拉框(佛/道分组),移动端复用底部面板并显示分组标题
- TTS 请求失败自动重试3次;普门品偈颂拆分修复600字上限问题
- 服务端缓存原子写(.tmp+rename),上限放宽至800字
- PWA:manifest + Service Worker,安装后无地址栏独立窗口
- 偏好记忆/逐字光标/回到诵读处/夜间模式等此前改动一并入库

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 15:47:18 +08:00

152 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""心经诵读站点后端:静态文件托管 + 千问 TTS 代理。"""
import hashlib
import json
import os
import urllib.error
import urllib.request
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
CACHE_DIR = BASE_DIR / "cache"
ENV_PATH = BASE_DIR / ".env"
def load_env(path: Path) -> None:
"""读取 .env 文件并注入环境变量(不覆盖已有变量)。"""
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
load_env(ENV_PATH)
API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE = os.environ.get(
"DASHSCOPE_BASE",
"https://llm-znfsxsdp6uwik7ad.cn-beijing.maas.aliyuncs.com/api/v1",
).rstrip("/")
TTS_MODEL = os.environ.get("TTS_MODEL", "qwen3-tts-flash")
PORT = int(os.environ.get("PORT", "8000"))
GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation"
def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict:
"""发送 JSON POST 请求并返回解析后的响应。"""
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def synthesize(text: str, voice: str) -> bytes:
"""调用千问 TTS 合成语音,返回 wav 音频字节流。"""
payload = {
"model": TTS_MODEL,
"input": {"text": text, "voice": voice},
}
data = http_post_json(GENERATION_URL, payload, {"Authorization": f"Bearer {API_KEY}"})
audio_url = (data.get("output") or {}).get("audio", {}).get("url")
if not audio_url:
raise RuntimeError(f"TTS 响应缺少音频地址:{json.dumps(data, ensure_ascii=False)[:300]}")
with urllib.request.urlopen(audio_url, timeout=60) as resp:
return resp.read()
class Handler(SimpleHTTPRequestHandler):
"""静态资源 + /api/tts 合成接口。"""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def do_POST(self) -> None:
"""处理 POST 请求,目前仅支持 /api/tts。"""
if self.path != "/api/tts":
self._send_json({"error": "not found"}, status=404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length).decode("utf-8"))
except (ValueError, json.JSONDecodeError):
self._send_json({"error": "请求体不是合法 JSON"}, status=400)
return
text = (body.get("text") or "").strip()
voice = (body.get("voice") or "").strip()
if not text or not voice:
self._send_json({"error": "缺少 text 或 voice 参数"}, status=400)
return
if len(text) > 800:
self._send_json({"error": "文本超过 800 字上限"}, status=400)
return
if not API_KEY:
self._send_json({"error": "服务端未配置 DASHSCOPE_API_KEY"}, status=500)
return
cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24]
cache_file = CACHE_DIR / f"{cache_key}.wav"
try:
if cache_file.exists():
audio = cache_file.read_bytes()
else:
audio = synthesize(text, voice)
CACHE_DIR.mkdir(exist_ok=True)
# 先写临时文件再原子重命名,避免中途崩溃留下半截 WAV 毒化缓存
tmp_file = cache_file.with_suffix(".tmp")
tmp_file.write_bytes(audio)
tmp_file.replace(cache_file)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:300]
self._send_json({"error": f"TTS 服务返回 {exc.code}:{detail}"}, status=502)
return
except Exception as exc: # noqa: BLE001 - 统一兜底,向前端返回可读错误
self._send_json({"error": f"语音合成失败:{exc}"}, status=502)
return
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.end_headers()
self.wfile.write(audio)
def _send_json(self, obj: dict, status: int = 200) -> None:
"""以 JSON 形式返回响应。"""
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def end_headers(self) -> None:
"""本地开发环境禁用静态资源缓存,避免页面拿到旧版 JS/CSS。"""
if not self.path.startswith("/api/"):
self.send_header("Cache-Control", "no-cache")
super().end_headers()
def log_message(self, fmt, *args) -> None:
"""精简请求日志输出。"""
if "/api/" in self.path:
super().log_message(fmt, *args)
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"心经诵读站点已启动:http://127.0.0.1:{PORT}")
print(f"TTS 模型:{TTS_MODEL} | 上游:{DASHSCOPE_BASE}")
if not API_KEY:
print("警告:未配置 DASHSCOPE_API_KEY,请复制 .env.example 为 .env 并填入密钥")
server.serve_forever()