feat: pilot explicit preview and selection reading with Hitokoto
Browse filesReuse the lazy independent CPU reader through a strict two-origin window/channel bridge. Preserve exact preview text and selection context, require explicit synthesis and active replacement, and keep search/filter/preview independent of the persistent player. No index/corpus/backend/model dependencies or SW cache-name changes.
- README.md +3 -1
- docs/TTS_PILOT.md +22 -0
- static/app.js +14 -1
- static/index.html +13 -0
- static/style.css +10 -0
- static/tts-client.js +191 -0
- tests/test_search.py +3 -0
- tests/test_tts_browser.py +271 -0
README.md
CHANGED
|
@@ -9,4 +9,6 @@ license: gpl-3.0
|
|
| 9 |
short_description: 给CCRD和CW的TXT提供搜索与全文搜索功能
|
| 10 |
---
|
| 11 |
|
| 12 |
-
VOMEBOOK Search is a txt-focused search Space for CCRD and CW bundles.
|
|
|
|
|
|
|
|
|
| 9 |
short_description: 给CCRD和CW的TXT提供搜索与全文搜索功能
|
| 10 |
---
|
| 11 |
|
| 12 |
+
VOMEBOOK Search is a txt-focused search Space for CCRD and CW bundles.
|
| 13 |
+
|
| 14 |
+
CCRD 的选文/预览朗读试点见 [docs/TTS_PILOT.md](docs/TTS_PILOT.md)。
|
docs/TTS_PILOT.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CCRD 朗读试点
|
| 2 |
+
|
| 3 |
+
在文章预览上方,可选“朗读选中文字”或“朗读当前预览”。首次点击后才载入
|
| 4 |
+
Hitokoto 的独立 CPU 播放器;文字通过严格限定来源的浏览器消息传入,不放进 URL。
|
| 5 |
+
**载入文字不等于提交合成**:在播放器确认正文、音色、合成语速和试验音高后,
|
| 6 |
+
主动点击“合成并试听”,才分批提交到 Hitokoto API。
|
| 7 |
+
|
| 8 |
+
- 选文必须完全在当前预览正文里,含原文 Unicode 与有限相邻读音上下文。
|
| 9 |
+
- 最多两万字;超长请自行选一段,不自动截断、不自动发送整本书。
|
| 10 |
+
- 当前快照开始朗读后,更换搜索词、排序、过滤、打开另一篇或关闭预览不会替换它。
|
| 11 |
+
- 播放器内支持暂停、句段跳读、倍速、睡眠定时、试验音高;高亮是句段级。
|
| 12 |
+
- “收起”或 Escape 保留朗读,“停止并收起”停止。重新载入会要求明确确认替换。
|
| 13 |
+
- 桌面保留搜索可操作;手机播放器独立弹层,收起后恢复预览。
|
| 14 |
+
- 试点播放器不自动读取/保存独立试听页的本机正文书签,页面内可续读。
|
| 15 |
+
- 模型、音色、缓存与任务限制来自 Hitokoto;API 不可用不影响搜索、预览和下载。
|
| 16 |
+
- 旧 Service Worker 客户端可能需第二次刷新/重开,缓存名不变。
|
| 17 |
+
- 实际手机后台、锁屏、自动播放限制、人工听感仍需真机确认,不承诺无缝。
|
| 18 |
+
|
| 19 |
+
生产搜索站:<https://vomebook-search.hf.space/>;语音服务:
|
| 20 |
+
<https://vomebook-hitokoto.hf.space/tts/>。这次只接入 CCRD,其他三个搜索站未改。
|
| 21 |
+
|
| 22 |
+
开发/验收命令见 `TESTING.md` 的 CCRD / Hitokoto reader pilot tier。
|
static/app.js
CHANGED
|
@@ -1174,6 +1174,7 @@ function restorePreviewFocus() {
|
|
| 1174 |
}
|
| 1175 |
|
| 1176 |
function clearPreviewDom() {
|
|
|
|
| 1177 |
setPreviewFullscreen(false);
|
| 1178 |
DOM.previewPanel.innerHTML = "";
|
| 1179 |
DOM.previewPanel.hidden = true;
|
|
@@ -1197,6 +1198,7 @@ async function openPreview(docId, keyword, options = {}) {
|
|
| 1197 |
syncUrl(history.state && history.state.preview === true);
|
| 1198 |
}
|
| 1199 |
if (previewController) previewController.abort();
|
|
|
|
| 1200 |
pauseSnippetLoadingForPreview();
|
| 1201 |
const controller = new AbortController();
|
| 1202 |
previewController = controller;
|
|
@@ -1231,7 +1233,8 @@ async function openPreview(docId, keyword, options = {}) {
|
|
| 1231 |
<button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
|
| 1232 |
<button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
|
| 1233 |
</div>
|
| 1234 |
-
|
|
|
|
| 1235 |
<pre class="preview-body"></pre>`;
|
| 1236 |
const previewTitle = DOM.previewPanel.querySelector(".preview-title");
|
| 1237 |
if (previewTitle) previewTitle.id = "preview-title";
|
|
@@ -1240,6 +1243,7 @@ async function openPreview(docId, keyword, options = {}) {
|
|
| 1240 |
const previewBody = DOM.previewPanel.querySelector(".preview-body");
|
| 1241 |
if (shouldHighlight) previewBody.innerHTML = text;
|
| 1242 |
else previewBody.textContent = rawText;
|
|
|
|
| 1243 |
DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
|
| 1244 |
setPreviewFullscreen(STATE.previewFullscreen);
|
| 1245 |
document.body.classList.add("preview-open");
|
|
@@ -2246,6 +2250,13 @@ function attachEvents() {
|
|
| 2246 |
}
|
| 2247 |
});
|
| 2248 |
DOM.previewPanel.addEventListener("click", (event) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2249 |
const closeBtn = event.target.closest("[data-action='close-preview']");
|
| 2250 |
if (closeBtn) {
|
| 2251 |
closePreview();
|
|
@@ -2257,7 +2268,9 @@ function attachEvents() {
|
|
| 2257 |
}
|
| 2258 |
if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
|
| 2259 |
});
|
|
|
|
| 2260 |
document.addEventListener("keydown", (event) => {
|
|
|
|
| 2261 |
if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
|
| 2262 |
if (event.key === "Escape") {
|
| 2263 |
event.preventDefault();
|
|
|
|
| 1174 |
}
|
| 1175 |
|
| 1176 |
function clearPreviewDom() {
|
| 1177 |
+
window.CCRDSpeech?.previewClosed();
|
| 1178 |
setPreviewFullscreen(false);
|
| 1179 |
DOM.previewPanel.innerHTML = "";
|
| 1180 |
DOM.previewPanel.hidden = true;
|
|
|
|
| 1198 |
syncUrl(history.state && history.state.preview === true);
|
| 1199 |
}
|
| 1200 |
if (previewController) previewController.abort();
|
| 1201 |
+
window.CCRDSpeech?.previewClosed();
|
| 1202 |
pauseSnippetLoadingForPreview();
|
| 1203 |
const controller = new AbortController();
|
| 1204 |
previewController = controller;
|
|
|
|
| 1233 |
<button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
|
| 1234 |
<button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
|
| 1235 |
</div>
|
| 1236 |
+
</div>
|
| 1237 |
+
<div class="preview-speech-tools"><button type="button" class="text-btn" data-speech="selection">朗读选中文字</button><button type="button" class="text-btn" data-speech="preview">朗读当前预览</button><button type="button" class="text-btn" data-speech="controls">播放器</button></div>
|
| 1238 |
<pre class="preview-body"></pre>`;
|
| 1239 |
const previewTitle = DOM.previewPanel.querySelector(".preview-title");
|
| 1240 |
if (previewTitle) previewTitle.id = "preview-title";
|
|
|
|
| 1243 |
const previewBody = DOM.previewPanel.querySelector(".preview-body");
|
| 1244 |
if (shouldHighlight) previewBody.innerHTML = text;
|
| 1245 |
else previewBody.textContent = rawText;
|
| 1246 |
+
window.CCRDSpeech?.previewLoaded({ id: docId, title: data.title, text: rawText });
|
| 1247 |
DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
|
| 1248 |
setPreviewFullscreen(STATE.previewFullscreen);
|
| 1249 |
document.body.classList.add("preview-open");
|
|
|
|
| 2250 |
}
|
| 2251 |
});
|
| 2252 |
DOM.previewPanel.addEventListener("click", (event) => {
|
| 2253 |
+
const speech = event.target.closest("[data-speech]");
|
| 2254 |
+
if (speech) {
|
| 2255 |
+
if (speech.dataset.speech === "controls") document.querySelector("#speech-launch")?.click();
|
| 2256 |
+
else if (window.CCRDSpeech) window.CCRDSpeech.read(speech.dataset.speech);
|
| 2257 |
+
else showToast("朗读组件尚未更新,请刷新页面后重试");
|
| 2258 |
+
return;
|
| 2259 |
+
}
|
| 2260 |
const closeBtn = event.target.closest("[data-action='close-preview']");
|
| 2261 |
if (closeBtn) {
|
| 2262 |
closePreview();
|
|
|
|
| 2268 |
}
|
| 2269 |
if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
|
| 2270 |
});
|
| 2271 |
+
if (!window.CCRDSpeech) $("#speech-launch")?.addEventListener("click", () => showToast("朗读组件加载失败,请刷新重试;搜索仍可使用"));
|
| 2272 |
document.addEventListener("keydown", (event) => {
|
| 2273 |
+
if (window.CCRDSpeech?.modal() || window.CCRDSpeech?.owns(event.target)) return;
|
| 2274 |
if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
|
| 2275 |
if (event.key === "Escape") {
|
| 2276 |
event.preventDefault();
|
static/index.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
| 51 |
<label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
|
| 52 |
</div>
|
| 53 |
<div class="status-right">
|
|
|
|
| 54 |
<button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
|
| 55 |
<div class="sort-group">
|
| 56 |
<span class="sort-label">排序:</span>
|
|
@@ -147,8 +148,20 @@
|
|
| 147 |
</footer>
|
| 148 |
<div id="overlay" class="overlay" style="display:none"></div>
|
| 149 |
<div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
|
|
|
| 152 |
<script src="/static/app.js"></script>
|
| 153 |
</body>
|
| 154 |
</html>
|
|
|
|
| 51 |
<label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
|
| 52 |
</div>
|
| 53 |
<div class="status-right">
|
| 54 |
+
<button type="button" id="speech-launch" class="text-btn" aria-controls="speech-dialog" aria-expanded="false">朗读</button>
|
| 55 |
<button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
|
| 56 |
<div class="sort-group">
|
| 57 |
<span class="sort-label">排序:</span>
|
|
|
|
| 148 |
</footer>
|
| 149 |
<div id="overlay" class="overlay" style="display:none"></div>
|
| 150 |
<div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
|
| 151 |
+
<dialog id="speech-dialog" class="speech-dialog" aria-labelledby="speech-title">
|
| 152 |
+
<div class="speech-heading">
|
| 153 |
+
<strong id="speech-title">CPU 朗读(试点)</strong>
|
| 154 |
+
<button type="button" id="speech-collapse" class="text-btn">收起(保留朗读)</button>
|
| 155 |
+
<button type="button" id="speech-stop" class="text-btn">停止并收起</button>
|
| 156 |
+
</div>
|
| 157 |
+
<p id="speech-status" class="speech-note" role="status">打开后连接 Hitokoto 独立语音服务。</p>
|
| 158 |
+
<button type="button" id="speech-retry" class="text-btn" hidden>重新连接(停止原朗读)</button>
|
| 159 |
+
<div id="speech-frame-host"></div>
|
| 160 |
+
<p class="speech-note">关闭预览或切换搜索不打断朗读。正文通过浏览器交给播放器,点击合成才提交语音 API。手机后台效果待真机确认。</p>
|
| 161 |
+
</dialog>
|
| 162 |
|
| 163 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
| 164 |
+
<script src="/static/tts-client.js"></script>
|
| 165 |
<script src="/static/app.js"></script>
|
| 166 |
</body>
|
| 167 |
</html>
|
static/style.css
CHANGED
|
@@ -48,6 +48,16 @@
|
|
| 48 |
color-scheme: dark;
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
body.light {
|
| 52 |
--surface: #fdfbff;
|
| 53 |
--surface-variant: #f0eff4;
|
|
|
|
| 48 |
color-scheme: dark;
|
| 49 |
}
|
| 50 |
|
| 51 |
+
.preview-speech-tools { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px; flex-shrink: 0; border-bottom: 1px solid var(--reader-divider); }
|
| 52 |
+
.speech-dialog { position: fixed; inset: auto 12px 12px auto; margin: 0; width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); padding: 10px; background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline); border-radius: 12px; box-shadow: var(--shadow-lg); z-index: 180; overflow: auto; user-select: text; }
|
| 53 |
+
.speech-dialog:not([open]) { display: none; }
|
| 54 |
+
.speech-dialog::backdrop { background: rgba(0, 0, 0, .45); }
|
| 55 |
+
.speech-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
| 56 |
+
.speech-heading strong { margin-right: auto; }
|
| 57 |
+
.speech-note { font-size: 12px; color: var(--on-surface-variant); margin: 8px 0; overflow-wrap: anywhere; }
|
| 58 |
+
#speech-frame-host iframe { display: block; width: 100%; height: min(62dvh, 660px); min-height: 220px; border: 0; background: var(--surface); }
|
| 59 |
+
body.mobile .speech-dialog { inset: 12px; width: auto; margin: auto; max-width: 650px; }
|
| 60 |
+
|
| 61 |
body.light {
|
| 62 |
--surface: #fdfbff;
|
| 63 |
--surface-variant: #f0eff4;
|
static/tts-client.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
// CCRD pilot: shared Hitokoto player in a persistent, isolated browsing context.
|
| 3 |
+
// No model code, browser speechSynthesis, remote text URLs or automatic TTS POSTs.
|
| 4 |
+
window.CCRDSpeech = (() => {
|
| 5 |
+
const ORIGIN = "https://vomebook-hitokoto.hf.space";
|
| 6 |
+
const PLAYER = ORIGIN + "/tts/?embed=ccrd-v1";
|
| 7 |
+
const $ = (selector) => document.querySelector(selector);
|
| 8 |
+
const dialog = $("#speech-dialog");
|
| 9 |
+
const host = $("#speech-frame-host");
|
| 10 |
+
const status = $("#speech-status");
|
| 11 |
+
const launch = $("#speech-launch");
|
| 12 |
+
const collapse = $("#speech-collapse");
|
| 13 |
+
const stopButton = $("#speech-stop");
|
| 14 |
+
const retry = $("#speech-retry");
|
| 15 |
+
if (!dialog || !host || !launch) return null; // Older cached HTML stays usable.
|
| 16 |
+
let frame = null, channel = "", connected = false, timeout, modalActive = false;
|
| 17 |
+
let pending = null, currentPreview = null, selected = null, focusReturn = null;
|
| 18 |
+
|
| 19 |
+
function notice(message) {
|
| 20 |
+
status.textContent = message;
|
| 21 |
+
if (typeof showToast === "function") showToast(message, 4000);
|
| 22 |
+
}
|
| 23 |
+
function token() {
|
| 24 |
+
return Array.from(crypto.getRandomValues(new Uint8Array(24)), (value) => value.toString(16).padStart(2, "0")).join("");
|
| 25 |
+
}
|
| 26 |
+
function send(type, data = {}) {
|
| 27 |
+
frame?.contentWindow?.postMessage({ type, channel, ...data }, ORIGIN);
|
| 28 |
+
}
|
| 29 |
+
function theme() { if (connected) send("ccrd-reader-theme", { dark: !document.body.classList.contains("light") }); }
|
| 30 |
+
function modal() { return dialog.open && modalActive; }
|
| 31 |
+
function owns(node) { return !!node && dialog.contains(node); }
|
| 32 |
+
function outerControls() {
|
| 33 |
+
return Array.from(dialog.querySelectorAll("button, a[href]")).filter(node => !node.disabled && !node.hidden && node.getClientRects().length);
|
| 34 |
+
}
|
| 35 |
+
function show() {
|
| 36 |
+
if (!dialog.open) {
|
| 37 |
+
focusReturn = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
| 38 |
+
if (document.body.classList.contains("mobile")) dialog.showModal();
|
| 39 |
+
else dialog.show();
|
| 40 |
+
modalActive = document.body.classList.contains("mobile");
|
| 41 |
+
}
|
| 42 |
+
launch.setAttribute("aria-expanded", "true");
|
| 43 |
+
collapse.focus({ preventScroll: true });
|
| 44 |
+
if (!frame) createFrame();
|
| 45 |
+
else if (connected) send("ccrd-reader-layout", { modal: modal() });
|
| 46 |
+
}
|
| 47 |
+
function hide() {
|
| 48 |
+
if (dialog.open) dialog.close();
|
| 49 |
+
modalActive = false;
|
| 50 |
+
if (connected) send("ccrd-reader-layout", { modal: false });
|
| 51 |
+
launch.setAttribute("aria-expanded", "false");
|
| 52 |
+
const previous = focusReturn;
|
| 53 |
+
if (previous?.isConnected && !previous.closest("[inert]")) previous.focus({ preventScroll: true });
|
| 54 |
+
else if (!launch.closest("[inert]")) launch.focus({ preventScroll: true });
|
| 55 |
+
}
|
| 56 |
+
function createFrame() {
|
| 57 |
+
clearTimeout(timeout);
|
| 58 |
+
connected = false;
|
| 59 |
+
channel = token();
|
| 60 |
+
frame = document.createElement("iframe");
|
| 61 |
+
frame.id = "speech-frame";
|
| 62 |
+
frame.title = "Hitokoto CPU 朗读播放器";
|
| 63 |
+
frame.allow = "autoplay";
|
| 64 |
+
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
| 65 |
+
frame.referrerPolicy = "no-referrer";
|
| 66 |
+
frame.addEventListener("load", () => {
|
| 67 |
+
connected = false;
|
| 68 |
+
send("ccrd-reader-init", { version: 1, modal: modal() });
|
| 69 |
+
});
|
| 70 |
+
frame.src = PLAYER; // Text/title/capabilities never go in the URL.
|
| 71 |
+
host.replaceChildren(frame);
|
| 72 |
+
status.textContent = "正在连接独立语音播放器,搜索和预览仍可使用…";
|
| 73 |
+
retry.hidden = true;
|
| 74 |
+
timeout = setTimeout(() => {
|
| 75 |
+
if (!connected) {
|
| 76 |
+
status.textContent = "播放器连接失败或仍在加载。可重连;不会影响搜索和原文预览。";
|
| 77 |
+
retry.hidden = false;
|
| 78 |
+
}
|
| 79 |
+
}, 20000);
|
| 80 |
+
}
|
| 81 |
+
function flush() {
|
| 82 |
+
if (!connected || !pending) return;
|
| 83 |
+
const payload = pending;
|
| 84 |
+
pending = null;
|
| 85 |
+
send("ccrd-reader-import", payload);
|
| 86 |
+
}
|
| 87 |
+
function previewLoaded(value) {
|
| 88 |
+
currentPreview = value;
|
| 89 |
+
selected = null;
|
| 90 |
+
}
|
| 91 |
+
function previewClosed() { currentPreview = null; selected = null; }
|
| 92 |
+
function selection() {
|
| 93 |
+
const body = $("#preview-panel .preview-body");
|
| 94 |
+
const value = window.getSelection();
|
| 95 |
+
if (!currentPreview || !body || !value || value.rangeCount !== 1) { selected = null; return; }
|
| 96 |
+
if (value.isCollapsed) {
|
| 97 |
+
if (!document.activeElement?.matches("[data-speech='selection']")) selected = null;
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
const range = value.getRangeAt(0);
|
| 101 |
+
if (!body.contains(range.startContainer) || !body.contains(range.endContainer)) { selected = null; return; }
|
| 102 |
+
const prefix = range.cloneRange();
|
| 103 |
+
prefix.selectNodeContents(body);
|
| 104 |
+
prefix.setEnd(range.startContainer, range.startOffset);
|
| 105 |
+
const start = prefix.toString().length;
|
| 106 |
+
const text = range.toString();
|
| 107 |
+
if (currentPreview.text.slice(start, start + text.length) === text) selected = { id: currentPreview.id, start, text };
|
| 108 |
+
}
|
| 109 |
+
function read(mode) {
|
| 110 |
+
selection();
|
| 111 |
+
if (!currentPreview) { notice("预览仍在加载,请等待正文出现后重试"); return; }
|
| 112 |
+
let text = currentPreview.text, before = "", after = "";
|
| 113 |
+
if (mode === "selection") {
|
| 114 |
+
if (!selected || selected.id !== currentPreview.id || !selected.text.trim()) {
|
| 115 |
+
notice("请先在当前预览正文内选中要朗读的文字"); return;
|
| 116 |
+
}
|
| 117 |
+
text = selected.text;
|
| 118 |
+
before = Array.from(currentPreview.text.slice(Math.max(0, selected.start - 128), selected.start)).slice(-32).join("");
|
| 119 |
+
after = Array.from(currentPreview.text.slice(selected.start + text.length, selected.start + text.length + 128)).slice(0, 32).join("");
|
| 120 |
+
}
|
| 121 |
+
let count = 0;
|
| 122 |
+
for (const _ of text) { if (++count > 20000) break; }
|
| 123 |
+
if (count > 20000) { notice("单次朗读最多两万字,请选中较短的一部分;不会截掉正文"); return; }
|
| 124 |
+
if (!text.trim()) { notice("当前预览没有可朗读正文"); return; }
|
| 125 |
+
pending = { text, title: String(currentPreview.title || "当前预览").slice(0, 200),
|
| 126 |
+
context_before: before, context_after: after };
|
| 127 |
+
show();
|
| 128 |
+
flush();
|
| 129 |
+
}
|
| 130 |
+
document.addEventListener("selectionchange", selection);
|
| 131 |
+
window.addEventListener("message", (event) => {
|
| 132 |
+
const data = event.data;
|
| 133 |
+
if (event.origin !== ORIGIN || event.source !== frame?.contentWindow || !data || data.channel !== channel) return;
|
| 134 |
+
if (data.type === "ccrd-reader-ready" && data.version === 1) {
|
| 135 |
+
clearTimeout(timeout);
|
| 136 |
+
connected = true;
|
| 137 |
+
retry.hidden = true;
|
| 138 |
+
theme();
|
| 139 |
+
status.textContent = "播放器已连接。载入正文后,确认文字并点击合成才向语音 API 提交。";
|
| 140 |
+
flush();
|
| 141 |
+
} else if (data.type === "ccrd-reader-loaded") {
|
| 142 |
+
status.textContent = `已载入 ${data.characters} 字。请在播放器确认文字、音色和音高后点击合成。`;
|
| 143 |
+
} else if (data.type === "ccrd-reader-rejected") {
|
| 144 |
+
status.textContent = "未替换正在朗读的文字;原朗读保留。";
|
| 145 |
+
} else if (data.type === "ccrd-reader-pending") {
|
| 146 |
+
status.textContent = "播放器正在朗读,请在播放器中选择替换文字或保留原朗读。";
|
| 147 |
+
} else if (data.type === "ccrd-reader-stopped") {
|
| 148 |
+
status.textContent = "朗读已停止,可收起或重新载入文字。";
|
| 149 |
+
} else if (data.type === "ccrd-reader-collapse") {
|
| 150 |
+
hide();
|
| 151 |
+
} else if (data.type === "ccrd-reader-focus-out" && modal() && typeof data.reverse === "boolean") {
|
| 152 |
+
const buttons = outerControls();
|
| 153 |
+
(data.reverse ? buttons[buttons.length - 1] : buttons[0])?.focus();
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
launch.addEventListener("click", show);
|
| 157 |
+
collapse.addEventListener("click", hide);
|
| 158 |
+
stopButton.addEventListener("click", () => {
|
| 159 |
+
if (connected) send("ccrd-reader-stop");
|
| 160 |
+
else { clearTimeout(timeout); frame?.remove(); frame = null; connected = false; pending = null; }
|
| 161 |
+
hide();
|
| 162 |
+
});
|
| 163 |
+
retry.addEventListener("click", () => {
|
| 164 |
+
if (!confirm("重新连接会停止此播放器中尚未完成的朗读,是否继续?")) return;
|
| 165 |
+
frame?.remove();
|
| 166 |
+
frame = null;
|
| 167 |
+
createFrame();
|
| 168 |
+
});
|
| 169 |
+
dialog.addEventListener("cancel", (event) => { event.preventDefault(); hide(); });
|
| 170 |
+
dialog.addEventListener("keydown", (event) => {
|
| 171 |
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); hide(); }
|
| 172 |
+
if (event.key === "Tab" && modal() && connected) {
|
| 173 |
+
const buttons = outerControls();
|
| 174 |
+
if (event.shiftKey && event.target === buttons[0]) {
|
| 175 |
+
event.preventDefault(); send("ccrd-reader-focus", { last: true });
|
| 176 |
+
} else if (!event.shiftKey && event.target === buttons[buttons.length - 1]) {
|
| 177 |
+
event.preventDefault(); send("ccrd-reader-focus", { last: false });
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
new MutationObserver(() => {
|
| 182 |
+
theme();
|
| 183 |
+
if (dialog.open) {
|
| 184 |
+
const targetModal = document.body.classList.contains("mobile");
|
| 185 |
+
if (modal() !== targetModal) { dialog.close(); targetModal ? dialog.showModal() : dialog.show(); modalActive = targetModal; }
|
| 186 |
+
if (connected) send("ccrd-reader-layout", { modal: modal() });
|
| 187 |
+
}
|
| 188 |
+
}).observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
| 189 |
+
window.addEventListener("pagehide", () => { if (connected) send("ccrd-reader-stop"); });
|
| 190 |
+
return { read, previewLoaded, previewClosed, modal, owns };
|
| 191 |
+
})();
|
tests/test_search.py
CHANGED
|
@@ -967,6 +967,7 @@ class FrontendContractTests(unittest.TestCase):
|
|
| 967 |
def test_every_static_control_exists_is_cached_and_has_behavior(self):
|
| 968 |
html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
|
| 969 |
source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")
|
|
|
|
| 970 |
parser = InteractiveIdParser()
|
| 971 |
parser.feed(html)
|
| 972 |
expected = {
|
|
@@ -978,6 +979,7 @@ class FrontendContractTests(unittest.TestCase):
|
|
| 978 |
"folder-toggle-all",
|
| 979 |
"source-filter-cancel", "folder-filter-cancel",
|
| 980 |
"filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
|
|
|
|
| 981 |
}
|
| 982 |
self.assertEqual(parser.ids, expected)
|
| 983 |
selector_exceptions = {"header-logo", "search-input"}
|
|
@@ -1002,6 +1004,7 @@ class FrontendContractTests(unittest.TestCase):
|
|
| 1002 |
"filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
|
| 1003 |
"filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
|
| 1004 |
"random-book-btn": "DOM.randomBookBtn",
|
|
|
|
| 1005 |
}
|
| 1006 |
for control_id, object_name in event_objects.items():
|
| 1007 |
self.assertIn(f'{object_name}.addEventListener(', source, control_id)
|
|
|
|
| 967 |
def test_every_static_control_exists_is_cached_and_has_behavior(self):
|
| 968 |
html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
|
| 969 |
source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")
|
| 970 |
+
source += (Path(app.BASE_DIR) / "static" / "tts-client.js").read_text(encoding="utf-8")
|
| 971 |
parser = InteractiveIdParser()
|
| 972 |
parser.feed(html)
|
| 973 |
expected = {
|
|
|
|
| 979 |
"folder-toggle-all",
|
| 980 |
"source-filter-cancel", "folder-filter-cancel",
|
| 981 |
"filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
|
| 982 |
+
"speech-launch", "speech-collapse", "speech-stop", "speech-retry",
|
| 983 |
}
|
| 984 |
self.assertEqual(parser.ids, expected)
|
| 985 |
selector_exceptions = {"header-logo", "search-input"}
|
|
|
|
| 1004 |
"filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
|
| 1005 |
"filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
|
| 1006 |
"random-book-btn": "DOM.randomBookBtn",
|
| 1007 |
+
"speech-launch": "launch", "speech-collapse": "collapse", "speech-stop": "stopButton", "speech-retry": "retry",
|
| 1008 |
}
|
| 1009 |
for control_id, object_name in event_objects.items():
|
| 1010 |
self.assertIn(f'{object_name}.addEventListener(', source, control_id)
|
tests/test_tts_browser.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit offline CCRD/Hitokoto cross-origin bridge and reader acceptance.
|
| 2 |
+
|
| 3 |
+
Requires Playwright, HITOKOTO_SOURCE_DIR and an existing CCRD_TTS_SAMPLE MP3.
|
| 4 |
+
Requests are routed fixtures; no production, corpus scan or model loading.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import re
|
| 12 |
+
import unittest
|
| 13 |
+
from urllib.parse import unquote, urlparse
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from playwright.sync_api import sync_playwright
|
| 17 |
+
except ImportError:
|
| 18 |
+
sync_playwright = None
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
SEARCH = "https://vomebook-search.hf.space"
|
| 22 |
+
SPEECH = "https://vomebook-hitokoto.hf.space"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@unittest.skipUnless(sync_playwright and os.environ.get("HITOKOTO_SOURCE_DIR") and os.environ.get("CCRD_TTS_SAMPLE"),
|
| 26 |
+
"explicit: set HITOKOTO_SOURCE_DIR and CCRD_TTS_SAMPLE, install Playwright/Chromium")
|
| 27 |
+
class SpeechReaderBrowserTests(unittest.TestCase):
|
| 28 |
+
def test_preview_selection_player_isolation_and_cross_origin_contract(self):
|
| 29 |
+
speech_root = Path(os.environ["HITOKOTO_SOURCE_DIR"])
|
| 30 |
+
mp3 = Path(os.environ["CCRD_TTS_SAMPLE"]).read_bytes()
|
| 31 |
+
source_text = "前言。𠮷这是一篇长文,校长文章写得很好,我得亲自处理。\n版本1.2.3与日期2026-10-18。" * 4
|
| 32 |
+
sources = [{"slug": "CCRD", "name": "CCRD", "count": 2}]
|
| 33 |
+
items = [{"doc_id": f"CCRD:{index}", "File": f"朗读测试{index}", "Source": "CCRD", "SourceName": "CCRD",
|
| 34 |
+
"Folder": ["测试"], "Size": 1234, "snippet": "长文与手机测试", "snippet_complete": True} for index in range(2)]
|
| 35 |
+
with sync_playwright() as playwright:
|
| 36 |
+
browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
|
| 37 |
+
try:
|
| 38 |
+
for mobile in (False, True):
|
| 39 |
+
with self.subTest(mobile=mobile):
|
| 40 |
+
context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900},
|
| 41 |
+
reduced_motion="reduce")
|
| 42 |
+
context.add_init_script("delete Navigator.prototype.serviceWorker")
|
| 43 |
+
context.add_init_script("document.addEventListener('DOMContentLoaded',()=>{const audio=document.querySelector('#audio');if(audio)audio.muted=true;})")
|
| 44 |
+
context.add_init_script("window.__bridge=[];addEventListener('message',event=>{if(event.data?.type?.startsWith('ccrd-reader-'))__bridge.push({origin:event.origin,data:event.data});})")
|
| 45 |
+
jobs, tts_posts, connections, deleted = {}, [], [], []
|
| 46 |
+
blocked = [False]
|
| 47 |
+
missing_client, old_html = [False], [False]
|
| 48 |
+
errors = []
|
| 49 |
+
|
| 50 |
+
def reply(route, data, status=200, headers=None):
|
| 51 |
+
route.fulfill(status=status, content_type="application/json", body=json.dumps(data), headers=headers or {})
|
| 52 |
+
|
| 53 |
+
def handler(route):
|
| 54 |
+
request = route.request
|
| 55 |
+
parsed = urlparse(request.url)
|
| 56 |
+
path = unquote(parsed.path)
|
| 57 |
+
if request.url.startswith(SEARCH):
|
| 58 |
+
if path == "/":
|
| 59 |
+
html = (ROOT / "static/index.html").read_text()
|
| 60 |
+
if old_html[0]:
|
| 61 |
+
html = re.sub(r'<button[^>]+id="speech-launch"[^>]*>.*?</button>', '', html)
|
| 62 |
+
html = re.sub(r'<dialog id="speech-dialog".*?</dialog>', '', html, flags=re.S)
|
| 63 |
+
html = html.replace('<script src="/static/tts-client.js"></script>', '')
|
| 64 |
+
route.fulfill(content_type="text/html", body=html)
|
| 65 |
+
elif path.startswith("/static/"):
|
| 66 |
+
if path == "/static/tts-client.js" and missing_client[0]:
|
| 67 |
+
route.abort("connectionfailed"); return
|
| 68 |
+
file = ROOT / path.lstrip("/")
|
| 69 |
+
if file.is_file():
|
| 70 |
+
route.fulfill(path=file)
|
| 71 |
+
else:
|
| 72 |
+
reply(route, {}, 404)
|
| 73 |
+
elif path == "/api/ping":
|
| 74 |
+
route.fulfill(status=204)
|
| 75 |
+
elif path == "/api/sources":
|
| 76 |
+
reply(route, sources)
|
| 77 |
+
elif path.startswith("/data/sidebar/"):
|
| 78 |
+
reply(route, {"sources": sources})
|
| 79 |
+
elif path.startswith("/data/initial/") or path.startswith("/api/search"):
|
| 80 |
+
reply(route, {"results": items, "total": 2, "page": 1, "page_size": 100, "q": "",
|
| 81 |
+
"sort": "relevance", "exact": True, "fulltext": True, "search_paths": True, "source": None})
|
| 82 |
+
elif path.startswith("/api/preview/"):
|
| 83 |
+
id = path.rsplit("/", 1)[-1]
|
| 84 |
+
reply(route, {"doc_id": id, "title": "朗读测试 <b>原文</b>", "source": "CCRD", "path": "测试/原文.txt",
|
| 85 |
+
"text": source_text if id == "CCRD:0" else "长"*20001 if id == "CCRD:long" else "另一个预览,不应该打断原朗读。"})
|
| 86 |
+
elif path == "/manifest.json":
|
| 87 |
+
reply(route, {"name": "Test"})
|
| 88 |
+
else:
|
| 89 |
+
reply(route, {}, 404)
|
| 90 |
+
elif request.url.startswith(SPEECH):
|
| 91 |
+
connections.append(request.url)
|
| 92 |
+
if blocked[0] and path == "/tts/":
|
| 93 |
+
route.abort("connectionfailed"); return
|
| 94 |
+
if path == "/tts/":
|
| 95 |
+
route.fulfill(content_type="text/html", body=(speech_root / "static/tts.html").read_bytes(),
|
| 96 |
+
headers={"Content-Security-Policy": "frame-ancestors " + SEARCH})
|
| 97 |
+
elif path in {"/tts/app.js", "/tts/reader.js", "/tts/embed.js", "/tts/style.css"}:
|
| 98 |
+
name = {"app.js": "tts.js", "style.css": "tts.css"}.get(path.rsplit("/", 1)[-1], path.rsplit("/", 1)[-1])
|
| 99 |
+
route.fulfill(path=speech_root / "static" / name)
|
| 100 |
+
elif path == "/tts/health":
|
| 101 |
+
reply(route, {"ready": True, "engine": {"threads": 2}, "queued": 0})
|
| 102 |
+
elif path == "/tts/voices":
|
| 103 |
+
reply(route, {"voices": [{"id": id, "name": id} for id in ("zf_001", "zf_017", "zm_010", "zm_029")]})
|
| 104 |
+
elif path == "/tts/jobs" and request.method == "POST":
|
| 105 |
+
body = request.post_data_json
|
| 106 |
+
tts_posts.append(body)
|
| 107 |
+
id = f"{len(jobs)+1:032d}"
|
| 108 |
+
length = len(body["text"].encode("utf-16-le")) // 2
|
| 109 |
+
jobs[id] = {"id": id, "state": "ready", "cache_hit": False, "result": {
|
| 110 |
+
"audio_url": f"/tts/audio/{id}.mp3", "audio_seconds": 10.6, "synthesis_seconds": .2,
|
| 111 |
+
"pitch_semitones": body["pitch_semitones"]}, "source_segments": [{"start": 0, "end": length}],
|
| 112 |
+
"progress": {"complete": True, "total_parts": 1, "parts": [{"index": 0, "start": 0, "end": length,
|
| 113 |
+
"audio_url": f"/tts/audio/{id}/parts/0.mp3", "first_audio_seconds": .2,
|
| 114 |
+
"audio_seconds": 10.6, "pitch_semitones": body["pitch_semitones"]}]}}
|
| 115 |
+
reply(route, jobs[id], 202)
|
| 116 |
+
elif path.startswith("/tts/jobs/"):
|
| 117 |
+
id = path.rsplit("/", 1)[-1]
|
| 118 |
+
if request.method == "DELETE": deleted.append(id)
|
| 119 |
+
reply(route, jobs.get(id, {"state": "error"}))
|
| 120 |
+
elif path.startswith("/tts/audio/"):
|
| 121 |
+
route.fulfill(content_type="audio/mpeg", body=mp3)
|
| 122 |
+
else:
|
| 123 |
+
reply(route, {}, 404)
|
| 124 |
+
else:
|
| 125 |
+
route.fulfill(status=204)
|
| 126 |
+
|
| 127 |
+
context.route("**/*", handler)
|
| 128 |
+
page = context.new_page()
|
| 129 |
+
page.on("pageerror", lambda error: errors.append(str(error)))
|
| 130 |
+
page.set_default_timeout(30000)
|
| 131 |
+
try:
|
| 132 |
+
page.goto(SEARCH + "/", wait_until="domcontentloaded")
|
| 133 |
+
page.locator(".result-item").first.wait_for()
|
| 134 |
+
self.assertFalse(connections, "Opening search must not load the speech service")
|
| 135 |
+
self.assertFalse(tts_posts)
|
| 136 |
+
page.locator("[data-action=preview]").first.click()
|
| 137 |
+
page.locator(".preview-body").wait_for()
|
| 138 |
+
self.assertEqual(page.locator(".preview-body").text_content(), source_text)
|
| 139 |
+
# No selection is an explicit error, not a fallback to full text.
|
| 140 |
+
page.locator("[data-speech=selection]").click()
|
| 141 |
+
self.assertFalse(connections)
|
| 142 |
+
selected_text = "这是一篇长文,校长文章写得很好,我��亲自处理。"
|
| 143 |
+
page.evaluate("""text=>{
|
| 144 |
+
const body=document.querySelector('.preview-body');const start=body.textContent.indexOf(text);
|
| 145 |
+
const walker=document.createTreeWalker(body,NodeFilter.SHOW_TEXT);let node;let offset=0;
|
| 146 |
+
const range=document.createRange();let begun=false;
|
| 147 |
+
while(node=walker.nextNode()){
|
| 148 |
+
if(!begun&&start<offset+node.length){range.setStart(node,start-offset);begun=true;}
|
| 149 |
+
if(begun&&start+text.length<=offset+node.length){range.setEnd(node,start+text.length-offset);break;}offset+=node.length;
|
| 150 |
+
}
|
| 151 |
+
getSelection().removeAllRanges();getSelection().addRange(range);
|
| 152 |
+
}""", selected_text)
|
| 153 |
+
page.locator("[data-speech=selection]").click()
|
| 154 |
+
page.wait_for_function("document.querySelector('#speech-status').textContent.includes('已载入')")
|
| 155 |
+
frame = page.frame_locator("#speech-frame")
|
| 156 |
+
self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
|
| 157 |
+
self.assertFalse(tts_posts, "Import alone must not submit speech")
|
| 158 |
+
self.assertFalse(frame.locator("#storage-controls").is_visible())
|
| 159 |
+
self.assertFalse(frame.locator("#remember").is_checked())
|
| 160 |
+
self.assertTrue(page.evaluate("CCRDSpeech.modal()") if mobile else not page.evaluate("CCRDSpeech.modal()"))
|
| 161 |
+
self.assertTrue(page.evaluate("document.documentElement.scrollWidth<=innerWidth+1"))
|
| 162 |
+
self.assertEqual(frame.locator("main b").count(), 0)
|
| 163 |
+
if mobile:
|
| 164 |
+
frame.locator("#playback-rate").focus()
|
| 165 |
+
page.keyboard.press("Tab")
|
| 166 |
+
page.wait_for_function("document.activeElement.id==='speech-collapse'")
|
| 167 |
+
# Same-origin spoof from the wrong Window/channel is ignored.
|
| 168 |
+
page.evaluate("""()=>window.postMessage({type:'ccrd-reader-loaded',channel:'0'.repeat(48),characters:99999},location.origin)""")
|
| 169 |
+
self.assertNotIn("99999", page.locator("#speech-status").text_content())
|
| 170 |
+
bridge_channel = page.evaluate("__bridge.find(event=>event.data.type==='ccrd-reader-ready').data.channel")
|
| 171 |
+
# The source origin alone is insufficient: a sibling
|
| 172 |
+
# Window with that origin and nonce is not the parent.
|
| 173 |
+
page.evaluate("""({channel,target})=>{
|
| 174 |
+
const sibling=document.createElement('iframe');sibling.hidden=true;document.body.append(sibling);
|
| 175 |
+
const data={type:'ccrd-reader-import',channel,text:'不应该载入',title:'伪造',context_before:'',context_after:''};
|
| 176 |
+
sibling.contentWindow.eval(`parent.document.querySelector('#speech-frame').contentWindow.postMessage(${JSON.stringify(data)},${JSON.stringify(target)})`);
|
| 177 |
+
setTimeout(()=>sibling.remove(),100);
|
| 178 |
+
}""", {"channel": bridge_channel, "target": SPEECH})
|
| 179 |
+
page.wait_for_timeout(200)
|
| 180 |
+
self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
|
| 181 |
+
frame.locator("#pitch").evaluate("n=>{n.value=-2;n.dispatchEvent(new Event('input',{bubbles:true}));}")
|
| 182 |
+
frame.locator("#playback-rate").evaluate("n=>{n.value=.5;n.dispatchEvent(new Event('input',{bubbles:true}));}")
|
| 183 |
+
frame.locator("#generate").click()
|
| 184 |
+
page.wait_for_function("document.querySelector('#speech-frame')!==null")
|
| 185 |
+
speech_page = next(f for f in page.frames if f.url.startswith(SPEECH))
|
| 186 |
+
speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
|
| 187 |
+
self.assertEqual(tts_posts[0]["text"], selected_text)
|
| 188 |
+
self.assertEqual(tts_posts[0]["pitch_semitones"], -2)
|
| 189 |
+
self.assertTrue(tts_posts[0]["context_before"].endswith("前言。𠮷"))
|
| 190 |
+
self.assertNotIn("原文.txt", json.dumps(tts_posts))
|
| 191 |
+
# Hiding/reopening keeps the exact iframe and audio session alive.
|
| 192 |
+
original_src = page.locator("#speech-frame").get_attribute("src")
|
| 193 |
+
page.locator("#speech-collapse").click()
|
| 194 |
+
self.assertFalse(page.locator("#speech-dialog").is_visible())
|
| 195 |
+
speech_page.wait_for_function("document.querySelector('#audio').currentTime>.3", polling=100)
|
| 196 |
+
self.assertFalse(deleted)
|
| 197 |
+
if mobile:
|
| 198 |
+
page.locator("[data-action=close-preview]").click()
|
| 199 |
+
page.wait_for_function("!STATE.previewDocId")
|
| 200 |
+
page.locator("#search-input").fill("手机")
|
| 201 |
+
page.wait_for_function("STATE.query==='手机' && !STATE.isLoading")
|
| 202 |
+
page.locator("#sort-select").select_option("name")
|
| 203 |
+
page.locator("[data-action=preview]").last.click()
|
| 204 |
+
page.wait_for_function("STATE.previewDocId==='CCRD:1'")
|
| 205 |
+
self.assertTrue(speech_page.evaluate("session!==null"))
|
| 206 |
+
self.assertFalse(deleted)
|
| 207 |
+
page.locator("[data-speech=controls]").click()
|
| 208 |
+
self.assertEqual(page.locator("#speech-frame").get_attribute("src"), original_src)
|
| 209 |
+
self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
|
| 210 |
+
page.locator("#speech-collapse").click()
|
| 211 |
+
page.locator("[data-speech=preview]").click()
|
| 212 |
+
frame.locator("#embed-keep").wait_for(state="visible")
|
| 213 |
+
self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
|
| 214 |
+
self.assertEqual(len(tts_posts), 1)
|
| 215 |
+
frame.locator("#embed-keep").click()
|
| 216 |
+
self.assertTrue(speech_page.evaluate("session!==null"))
|
| 217 |
+
page.locator("#speech-collapse").click()
|
| 218 |
+
page.locator("[data-speech=preview]").click()
|
| 219 |
+
frame.locator("#embed-replace").click()
|
| 220 |
+
self.assertEqual(frame.locator("#speech-text").input_value(), "另一个预览,不应该打断原朗读。")
|
| 221 |
+
self.assertEqual(len(tts_posts), 1, "Replacement requires a separate explicit synthesis click")
|
| 222 |
+
frame.locator("#generate").click()
|
| 223 |
+
speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
|
| 224 |
+
self.assertEqual(tts_posts[-1]["text"], "另一个预览,不应该打断原朗读。")
|
| 225 |
+
frame.locator("#speech-text").press("Escape")
|
| 226 |
+
page.wait_for_function("!document.querySelector('#speech-dialog').open")
|
| 227 |
+
page.locator("[data-speech=controls]").click()
|
| 228 |
+
page.locator("#speech-stop").click()
|
| 229 |
+
speech_page.wait_for_function("session===null", polling=100)
|
| 230 |
+
self.assertTrue(deleted)
|
| 231 |
+
count = len(tts_posts)
|
| 232 |
+
page.evaluate("openPreview('CCRD:long','')")
|
| 233 |
+
page.wait_for_function("document.querySelector('.preview-body').textContent.length===20001")
|
| 234 |
+
page.locator("[data-speech=preview]").click()
|
| 235 |
+
self.assertEqual(len(tts_posts), count)
|
| 236 |
+
self.assertIn("两万字", page.locator("#speech-status").text_content())
|
| 237 |
+
# Offline player failure leaves real search/preview
|
| 238 |
+
# code working; no auto retry loop or speech POST.
|
| 239 |
+
blocked[0] = True
|
| 240 |
+
page.goto(SEARCH + "/", wait_until="domcontentloaded")
|
| 241 |
+
page.locator(".result-item").first.wait_for()
|
| 242 |
+
page.wait_for_function("!STATE.isLoading")
|
| 243 |
+
page.evaluate("const native=setTimeout;window.setTimeout=(fn,ms,...args)=>native(fn,ms===20000?100:ms,...args)")
|
| 244 |
+
if not page.locator(".preview-body").count():
|
| 245 |
+
page.locator("[data-action=preview]").first.click()
|
| 246 |
+
page.locator("[data-speech=preview]").click()
|
| 247 |
+
page.locator("#speech-retry").wait_for(state="visible")
|
| 248 |
+
page.locator("#speech-collapse").click()
|
| 249 |
+
if mobile:
|
| 250 |
+
page.locator("[data-action=close-preview]").click()
|
| 251 |
+
page.wait_for_function("!STATE.previewDocId")
|
| 252 |
+
page.locator("#search-input").fill("朗读故障后仍能搜索")
|
| 253 |
+
page.wait_for_function("STATE.query==='朗读故障后仍能搜索' && !STATE.isLoading")
|
| 254 |
+
self.assertEqual(page.locator(".result-item").count(), 2)
|
| 255 |
+
self.assertEqual(len(tts_posts), count)
|
| 256 |
+
# New HTML with a missing bridge, and an old cached
|
| 257 |
+
# shell with new app.js, must both retain search.
|
| 258 |
+
for mode in ('missing-client', 'old-html'):
|
| 259 |
+
missing_client[0] = mode == 'missing-client'
|
| 260 |
+
old_html[0] = mode == 'old-html'
|
| 261 |
+
page.goto(SEARCH + "/", wait_until="domcontentloaded")
|
| 262 |
+
page.locator(".result-item").first.wait_for()
|
| 263 |
+
page.locator("#search-input").fill("缓存兼容")
|
| 264 |
+
page.wait_for_function("STATE.query==='缓存兼容' && !STATE.isLoading")
|
| 265 |
+
self.assertEqual(page.locator(".result-item").count(), 2)
|
| 266 |
+
self.assertEqual(len(tts_posts), count)
|
| 267 |
+
self.assertFalse(errors, errors)
|
| 268 |
+
finally:
|
| 269 |
+
context.close()
|
| 270 |
+
finally:
|
| 271 |
+
browser.close()
|