export function createReaderSource(readerContext) { function readerContentUrl(url) { try { const parsed = new URL(url, location.href); if (parsed.origin === location.origin && parsed.pathname === "/api/reader-bucket-resource") return parsed.href; if (parsed.origin === "https://huggingface.co" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash) { const prefix = /^\/buckets\/(vomebook\/(?:pdf-pages-v2|reader-assets-v2))\/resolve\//.exec(parsed.pathname); const path = prefix && decodeURIComponent(parsed.pathname.slice(prefix[0].length)); if (path && VoiceOfMLReader.isBucketPath(path, true, prefix[1])) return `/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; } } catch (_) {} return `/api/reader-content?url=${encodeURIComponent(url)}`; } function readerDownloadApiUrl(path) { return path; } function readerDownloadUrl(filename, link) { return readerContext.readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`); } async function startReaderDownload(event) { event.preventDefault(); const button = event.currentTarget; if (button.hasAttribute("aria-busy")) return; readerContext.showDownloadFeedback("正在准备下载…", 0); const controller = new AbortController(); let timer; button.setAttribute("aria-busy", "true"); try { // ID-only links share the Reader's in-flight resolution, not its render phase. if (!readerContext.downloadUrl) await readerContext.readerSourcePromise; if (!readerContext.downloadUrl) throw new Error("无法获取原文件地址"); const filename = readerContext.documentState.title || "file"; const checkUrl = readerContext.readerDownloadApiUrl(`/api/download/check?link=${encodeURIComponent(readerContext.downloadUrl)}`); timer = setTimeout(() => controller.abort(), 8000); const response = await fetch(checkUrl, { signal: controller.signal, cache: "no-store" }); const result = await response.json().catch(() => null); if (!response.ok || result?.ok !== true) throw new Error(result?.error || "下载检查失败,请重试"); if (readerContext.readerLifecycle.disposed) return; const frame = document.createElement("iframe"); frame.hidden = true; frame.setAttribute("aria-hidden", "true"); frame.src = readerContext.readerDownloadUrl(filename, readerContext.downloadUrl); document.body.appendChild(frame); setTimeout(() => frame.remove(), 60000); readerContext.showDownloadFeedback("已发起下载"); } catch (error) { if (!readerContext.readerLifecycle.disposed) { readerContext.showDownloadFeedback(error?.name === "AbortError" ? "下载检查超时,请重试" : (error.message || "下载失败,请重试")); } } finally { clearTimeout(timer); button.removeAttribute("aria-busy"); } } function normalizeSourceUrl(url) { return url.startsWith("/api/reader-bucket-resource?") ? new URL(url, location.origin).href : url; } async function resolveReaderId(id) { const endpoint = `/api/reader-resolve?id=${encodeURIComponent(id)}`; for (let attempt = 0; attempt < 2; attempt++) { const early = window.__VOICE_READER_RESOLVE__; if (!attempt && early?.id === id) { window.__VOICE_READER_RESOLVE__ = null; const untrack = readerContext.trackReaderResource(early.dispose); let result; try { result = await readerContext.awaitReader(early.promise); } catch (error) { readerContext.assertReaderActive(); if (!["TimeoutError", "TypeError"].includes(error?.name)) throw error; continue; } finally { untrack(); early.dispose(); } if (result.ok) return result.data; if (![408, 429, 500, 502, 503, 504].includes(result.status)) throw new Error(`HTTP ${result.status}`); await readerContext.waitForReader(350); continue; } const response = await readerContext.readerRequestManager.requestBuffered( endpoint, readerContext.READER_PROXY_TIMEOUT_MS, VoiceOfMLReaderSecurity.LIMITS.manifestBytes); if (response.ok) return response.json(); await response.body?.cancel(); if (![408, 429, 500, 502, 503, 504].includes(response.status) || attempt) throw new Error(`HTTP ${response.status}`); await readerContext.waitForReader(350); } throw new Error("Reader ID resolution failed"); } function applyReaderMetadata(data) { if (data) readerContext.resolvedReaderData = { ...(readerContext.resolvedReaderData || {}), ...data }; if (data?.ReaderOcrManifest || data?.ocr_manifest) { readerContext.ocrManifestUrl = data.ReaderOcrManifest || data.ocr_manifest; readerContext.params.set("ocr_manifest", readerContext.ocrManifestUrl); } readerContext.readerRuntime.update("source", { metadata: readerContext.resolvedReaderData }); const title = String(readerContext.resolvedReaderData?.title || readerContext.params.get("title") || readerContext.sourceName).trim(); const suffix = String( readerContext.resolvedReaderData?.original_extension || (readerContext.extension === "pdf-pages" ? "pdf" : readerContext.extension) ) .toLowerCase() .replace(/^\./, ""); const value = title + (suffix && !title.toLowerCase().endsWith(`.${suffix}`) ? `.${suffix}` : ""); readerContext.updateDocumentState({ title: value }); document.querySelector("#title").textContent = value; document.title = `${value} - VoiceOfML Reader`; readerContext.applyReaderPathMetadata(readerContext.resolvedReaderData); } function applyReaderPathMetadata(data) { const folder = Array.isArray(data?.folder) ? data.folder.join("/") : String(data?.folder || ""); const value = String( data?.path || [data?.repo, folder].filter(Boolean).join("/") || readerContext.readerPathLabel ).replace(/^\/+|\/+$/g, ""); readerContext.folderNavigationTarget = null; try { const target = new URL(readerContext.folderReturnUrl, location.origin); if ( readerContext.folderReturnUrl && target.origin === location.origin && target.pathname.split("/").filter(Boolean).length === 1 ) readerContext.folderNavigationTarget = target; } catch (_) {} if (!readerContext.folderNavigationTarget && data?.repo) { readerContext.folderNavigationTarget = new URL("/" + encodeURIComponent(data.repo), location.origin); if (folder) readerContext.folderNavigationTarget.searchParams.set("folder_self", folder); } readerContext.readerPath.textContent = value; readerContext.readerPath.hidden = !value; readerContext.readerPath.disabled = !readerContext.folderNavigationTarget; readerContext.readerPath.setAttribute("aria-label", `筛选文件夹:${value}`); } function validSource(raw) { try { const url = new URL(raw); const bucketPath = url.searchParams.get("path") || ""; if ( (url.origin === location.origin || url.origin === "https://voiceofml-search.hf.space") && url.pathname === "/api/reader-bucket-resource" && url.searchParams.getAll("path").length === 1 && [...url.searchParams.keys()].every((key) => key === "path") && (VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/pdf-pages-v2") || VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) ) return true; if (url.protocol === "https:" && url.hostname === "huggingface.co" && (url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") || url.pathname.startsWith("/buckets/vomebook/reader-assets-v2/resolve/")) && !url.search && !url.hash) { const prefix = url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") ? "/buckets/vomebook/pdf-pages-v2/resolve/" : "/buckets/vomebook/reader-assets-v2/resolve/"; const path = decodeURIComponent(url.pathname.slice(prefix.length)); const bucket = prefix.includes("pdf-pages-v2") ? "vomebook/pdf-pages-v2" : "vomebook/reader-assets-v2"; return VoiceOfMLReader.isBucketPath(path, true, bucket); } if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false; const readerAsset = VoiceOfMLReader.isAssetSourcePath(url.pathname); return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset; } catch (_) { return false; } } function validOcr(raw) { try { const url = new URL(raw, location.origin); return url.origin === location.origin && url.pathname.startsWith("/txt/"); } catch (_) { return false; } } function validFallback(raw) { try { const url = new URL(raw); return ( url.protocol === "https:" && ["huggingface.co", "hf-mirror.com"].includes(url.hostname) && /\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:linearized\.pdf|(?:[a-z0-9-]+\/)?document\.pdf)$/.test( url.pathname ) ); } catch (_) { return false; } } function readerAssetObjectFamily(raw, base = location.href) { try { const url = new URL(raw, base); if (url.origin === "https://voiceofml-search.hf.space" && url.pathname === "/api/reader-bucket-resource") { const bucketPath = url.searchParams.get("path") || ""; const match = bucketPath.match(/^(chapters\/ebook\/(?:epub|mobi|azw3|fb2|chm)\/[0-9a-f]{64}\/[0-9a-f]{16}\/)/); if (url.hash || url.searchParams.getAll("path").length !== 1 || [...url.searchParams.keys()].some((key) => key !== "path") || !match || !VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) return null; return { url, prefix: match[1], bucket: true, bucketPath }; } if ( url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname) || url.username || url.password ) return null; const match = url.pathname.match( /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?)/ ); return match ? { url, prefix: url.pathname.slice(0, url.pathname.indexOf(match[1])) + match[1], bucket: false } : null; } catch (_) { return null; } } function trustedReaderAssetManifestUrl(raw) { const family = readerContext.readerAssetObjectFamily(raw); const manifestPath = family?.bucket ? family.bucketPath : family?.url.pathname; return family && (family.bucket || !family.url.search) && (family.bucket || !family.url.hash) && /\/chapter-manifest\.json$/i.test(manifestPath || "") ? family.url.href : null; } function trustedChapterUrl(raw, base) { const manifest = readerContext.readerAssetObjectFamily(base); const manifestPath = manifest?.bucket ? manifest.bucketPath : new URL(base, location.href).pathname; if (!manifest || !/\/chapter-manifest\.json$/i.test(manifestPath)) return null; try { if (manifest.bucket) { const url = new URL(String(raw || ""), `https://bucket.invalid/${manifest.bucketPath}`); const path = decodeURIComponent(url.pathname.slice(1)); if (url.origin !== "https://bucket.invalid" || url.search || url.hash || !path.startsWith(manifest.prefix) || !/(?:\/chapters\/chapter-[0-9]{4}\.xhtml|\/epub-search-index\.json\.gz)$/i.test(path) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; return `${manifest.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; } const url = new URL(raw, base); return !url.search && !url.hash && url.origin === manifest.url.origin && url.pathname.startsWith(manifest.prefix) && /(?:\.(?:xhtml?|html?)|\/epub-search-index\.json\.gz)$/i.test(url.pathname) ? url.href : null; } catch (_) { return null; } } function trustedChapterReferenceUrl(raw, base, manifestBase) { const family = readerContext.readerAssetObjectFamily(manifestBase); if (!family) return null; try { if (family.bucket) { const current = readerContext.readerAssetObjectFamily(base); if (!current?.bucket) return null; const url = new URL(String(raw || ""), `https://bucket.invalid/${current.bucketPath}`); const path = decodeURIComponent(url.pathname.slice(1)); if (url.origin !== "https://bucket.invalid" || url.search || !path.startsWith(family.prefix) || !/\/chapters\/chapter-[0-9]{4}\.xhtml$/i.test(path) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; return { url: `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`, fragment: url.hash }; } const url = new URL(raw, base); return !url.search && url.origin === family.url.origin && url.pathname.startsWith(family.prefix) && /\/(?:chapters\/)?[^/]+\.xhtml?$/i.test(url.pathname) ? { url: url.href, fragment: url.hash } : null; } catch (_) { return null; } } function trustedChapterResourceUrl(raw, base, attribute, manifestBase) { const value = String(raw || "").trim(); if (!value || /^(?:data:|mailto:|tel:|javascript:)/i.test(value)) return null; // Chapters and resources can be siblings within the manifest's asset directory. const family = readerContext.readerAssetObjectFamily(manifestBase); if (!family) return null; try { if (family.bucket) { const current = readerContext.readerAssetObjectFamily(base); if (!current?.bucket) return null; const url = new URL(value, `https://bucket.invalid/${current.bucketPath}`); const path = decodeURIComponent(url.pathname.slice(1)); if (url.origin !== "https://bucket.invalid" || url.search || url.hash || !path.startsWith(family.prefix) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; const resource = `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; return attribute === "href" ? resource : resource; } const url = new URL(value, base), path = url.pathname.toLowerCase(); if (url.origin !== family.url.origin || !url.pathname.startsWith(family.prefix)) return null; const allowed = attribute === "href" ? /\.(?:x?html?|css|svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test( path ) : /\.(?:svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(path); return allowed ? url.href : null; } catch (_) { return null; } } return { readerContentUrl, readerDownloadApiUrl, readerDownloadUrl, startReaderDownload, normalizeSourceUrl, resolveReaderId, applyReaderMetadata, applyReaderPathMetadata, validSource, validOcr, validFallback, readerAssetObjectFamily, trustedReaderAssetManifestUrl, trustedChapterUrl, trustedChapterReferenceUrl, trustedChapterResourceUrl }; }