feat: load NovelFull chapters directly for audiobooks

This commit is contained in:
Ethanfel committed 2026-10-12 10:47:32 +02:00
1 parent b4c1cb2955
commit 5a5505487c
6 files changed
+1799 -2

No files matched your search

+36
View File
@@ -10,6 +10,7 @@ A ComfyUI custom node for [OmniVoice](https://github.com/k2-fsa/OmniVoice) — a
- **Voice Presets** — built-in curated reference voices, ready to use without any audio file
- **Voice Mixing** — blend two or three reference voices for a hybrid speaker
- **EPUB Loader** — load chapters from an ebook directly into the pipeline
- **Web Novel Loader** — load NovelFull chapters directly from a novel URL, with local caching
- **Audiobook-ready** — handles arbitrarily long text with near-constant VRAM via built-in chunking
- **Multilingual** — 600+ languages
@@ -125,6 +126,41 @@ Loads an EPUB file and outputs selected chapters as plain text, ready to pipe in
**Outputs:** `text` (STRING) — selected chapters joined by `---`, `chapter_list` (STRING) — numbered list of all chapter titles for reference.
### OmniVoice Web Novel Loader
Loads chapters directly from a NovelFull novel URL, such as
`https://novelfull.net/release-that-witch.html`. No EPUB conversion is needed.
| Input | Description |
|-------|-------------|
| `novel_url` | NovelFull's main novel page, rather than an individual chapter page |
| `chapter_start` | First chapter in reading order, starting at 1 |
| `chapter_end` | Last chapter, inclusive; connect the same counter to both inputs for one chapter per audio file |
| `refresh_cache` | Re-download the index and selected chapters; leave off for normal batch generation |
**Outputs:** `text`, `chapter_title`, `chapter_list` — the same outputs as the EPUB loader.
The body excludes the leading chapter heading and translator/editor credits, and filters
injected `ReadNovelFull.com` lines (including `freёReadNovelFull.com`). Connect
`chapter_title` to your existing title-and-body join to speak the title once.
The first run reads all pages of the chapter index. Only the selected chapter bodies
are downloaded. The index and chapter text are cached under
`ComfyUI/models/omnivoice/web_novels/`, so previously loaded chapters can be reused
offline. A counter past the final chapter raises an error instead of repeating the
last chapter. Currently, the supported website is `novelfull.net`.
The included `workflows/novelfull_release_that_witch.json` adapts the existing
chapter-by-chapter audiobook workflow:
- An incrementing integer feeds both chapter inputs, starting at 1.
- The title is joined with the body once, preserving the existing text filters.
- The voice reference, generation settings, and FLAC output are preserved.
- The filename prefix follows `Release That Witch/<chapter index> <chapter title>`.
Restart ComfyUI after updating the extension, load the workflow, and set the integer
counter to your starting chapter. Queue one run per desired chapter. The reference
audio must still be available at the path selected by the Load Audio node.
## Default Workflow
A ready-to-use workflow is included at `workflows/voice_cloning.json`:
+3 -1
View File
@@ -1,9 +1,10 @@
from .nodes import OmniVoiceModelLoader, OmniVoiceGenerate, OmniVoiceEpubLoader, OmniVoiceVoicePreset, OmniVoiceMixVoices, OmniVoiceVoiceDesign, OmniVoiceSpeaker, OmniVoiceSpeakers
from .nodes import OmniVoiceModelLoader, OmniVoiceGenerate, OmniVoiceEpubLoader, OmniVoiceWebNovelLoader, OmniVoiceVoicePreset, OmniVoiceMixVoices, OmniVoiceVoiceDesign, OmniVoiceSpeaker, OmniVoiceSpeakers
NODE_CLASS_MAPPINGS = {
"OmniVoiceModelLoader": OmniVoiceModelLoader,
"OmniVoiceGenerate": OmniVoiceGenerate,
"OmniVoiceEpubLoader": OmniVoiceEpubLoader,
"OmniVoiceWebNovelLoader": OmniVoiceWebNovelLoader,
"OmniVoiceVoicePreset": OmniVoiceVoicePreset,
"OmniVoiceMixVoices": OmniVoiceMixVoices,
"OmniVoiceVoiceDesign": OmniVoiceVoiceDesign,
@@ -15,6 +16,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"OmniVoiceModelLoader": "OmniVoice Model Loader",
"OmniVoiceGenerate": "OmniVoice Generate",
"OmniVoiceEpubLoader": "OmniVoice EPUB Loader",
"OmniVoiceWebNovelLoader": "OmniVoice Web Novel Loader",
"OmniVoiceVoicePreset": "OmniVoice Voice Preset",
"OmniVoiceMixVoices": "OmniVoice Mix Voices",
"OmniVoiceVoiceDesign": "OmniVoice Voice Design",
+2 -1
View File
@@ -1,9 +1,10 @@
from .loader import OmniVoiceModelLoader
from .generator import OmniVoiceGenerate
from .epub_loader import OmniVoiceEpubLoader
from .web_novel_loader import OmniVoiceWebNovelLoader
from .voice_presets import OmniVoiceVoicePreset
from .mix_voices import OmniVoiceMixVoices
from .voice_design import OmniVoiceVoiceDesign
from .multi_speaker import OmniVoiceSpeaker, OmniVoiceSpeakers
__all__ = ["OmniVoiceModelLoader", "OmniVoiceGenerate", "OmniVoiceEpubLoader", "OmniVoiceVoicePreset", "OmniVoiceMixVoices", "OmniVoiceVoiceDesign", "OmniVoiceSpeaker", "OmniVoiceSpeakers"]
__all__ = ["OmniVoiceModelLoader", "OmniVoiceGenerate", "OmniVoiceEpubLoader", "OmniVoiceWebNovelLoader", "OmniVoiceVoicePreset", "OmniVoiceMixVoices", "OmniVoiceVoiceDesign", "OmniVoiceSpeaker", "OmniVoiceSpeakers"]
+239
View File
@@ -0,0 +1,239 @@
"""Load NovelFull chapters into the same text pipeline as the EPUB loader."""
import hashlib
import json
import logging
import os
from pathlib import Path
import re
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, urljoin, urlsplit, urlunsplit
from urllib.request import Request, urlopen
from bs4 import BeautifulSoup
try:
import folder_paths
_CACHE_DIR = Path(folder_paths.models_dir) / "omnivoice" / "web_novels"
except ImportError:
_CACHE_DIR = Path.home() / ".cache" / "omnivoice" / "web_novels"
_LOG = logging.getLogger(__name__)
_CHAPTER_HEADING = re.compile(r"^chapter\s*(\d+)\b", re.IGNORECASE)
# Match the site's injected footer, including its Cyrillic/Unicode spelling.
_WATERMARK = re.compile(
r"(?:fr[eеёéè]{2}\s*)?read\s*novel\s*full\s*\.\s*(?:com|net)",
re.IGNORECASE,
)
_CREDITS = re.compile(r"^(?:translator|editor|translation)\s*:", re.IGNORECASE)
def _validate_url(url):
parts = urlsplit(url.strip())
if (parts.scheme not in {"http", "https"}
or parts.hostname not in {"novelfull.net", "www.novelfull.net"}
or parts.username or parts.password or parts.port not in {None, 80, 443}):
raise ValueError("Use a public NovelFull URL on https://novelfull.net/.")
return parts
def _novel_url(url):
parts = _validate_url(url)
if not re.fullmatch(r"/[^/]+\.html", parts.path):
raise ValueError("Paste the novel's chapter-list URL, not an individual chapter URL.")
# Always begin with the first page, even if the pasted URL has ?page=N.
return urlunsplit(("https", "novelfull.net", parts.path, "", ""))
def _fetch_html(url):
_validate_url(url)
for attempt in range(3):
try:
request = Request(url, headers={"User-Agent": "ComfyUI-OmniVoice/1.0"})
with urlopen(request, timeout=20) as response:
_validate_url(response.geturl())
return response.read().decode("utf-8", errors="replace")
except HTTPError as exc:
if exc.code in {429, 500, 502, 503, 504} and attempt < 2:
time.sleep(2 ** (attempt + 1))
continue
raise RuntimeError(f"NovelFull returned HTTP {exc.code} for {url}.") from exc
except (URLError, TimeoutError, OSError) as exc:
if attempt < 2:
time.sleep(2 ** (attempt + 1))
continue
raise RuntimeError(f"Could not download {url}: {exc}") from exc
def _read_cache(path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _write_cache(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
suffix=".tmp", delete=False) as handle:
temp_path = Path(handle.name)
try:
json.dump(value, handle, ensure_ascii=False)
except BaseException:
temp_path.unlink(missing_ok=True)
raise
try:
os.replace(temp_path, path)
finally:
temp_path.unlink(missing_ok=True)
def _parse_index(html, novel_url):
soup = BeautifulSoup(html, "html.parser")
container = soup.select_one("#list-chapter, .list-chapter")
if container is None:
raise ValueError("NovelFull's chapter list was not found. The page may be blocked or its layout changed.")
chapter_prefix = urlsplit(novel_url).path.removesuffix(".html") + "/"
chapters = []
seen = set()
for anchor in container.select("a[href]"):
url = urljoin(novel_url, anchor["href"])
parts = urlsplit(url)
if (parts.hostname != "novelfull.net" or not parts.path.startswith(chapter_prefix)
or not parts.path.endswith(".html")):
continue
url = urlunsplit(("https", "novelfull.net", parts.path, "", ""))
title = " ".join(anchor.get_text(" ", strip=True).split())
if title and url not in seen:
seen.add(url)
chapters.append({"title": title, "url": url})
if not chapters:
raise ValueError("NovelFull's chapter list is empty; no audio will be generated.")
# New layout publishes the total; old layouts expose it through pagination.
pages = int(container.get("data-total-page", "1"))
for anchor in container.select("a[href]"):
parts = urlsplit(urljoin(novel_url, anchor["href"]))
if parts.hostname == "novelfull.net" and parts.path == urlsplit(novel_url).path:
number = parse_qs(parts.query).get("page", ["1"])[0]
if number.isdigit():
pages = max(pages, int(number))
if not 1 <= pages <= 1000:
raise ValueError("NovelFull reported an invalid number of chapter-list pages.")
return chapters, pages
def _load_index(novel_url, cache_dir, refresh):
path = cache_dir / "index.json"
cached = _read_cache(path) if not refresh else None
if isinstance(cached, list) and cached and all(
isinstance(ch, dict) and ch.get("title") and ch.get("url") for ch in cached):
return cached
chapters, pages = _parse_index(_fetch_html(novel_url), novel_url)
seen = {ch["url"] for ch in chapters}
for page in range(2, pages + 1):
_LOG.info("OmniVoice: reading NovelFull chapter-list page %s/%s", page, pages)
time.sleep(0.25)
batch, _ = _parse_index(_fetch_html(f"{novel_url}?page={page}"), novel_url)
if not any(ch["url"] not in seen for ch in batch):
raise ValueError(f"NovelFull repeated chapter-list page {page}; the index is incomplete.")
for chapter in batch:
if chapter["url"] not in seen:
chapters.append(chapter)
seen.add(chapter["url"])
_write_cache(path, chapters)
return chapters
def _parse_chapter(html, chapter):
soup = BeautifulSoup(html, "html.parser")
content = soup.select_one("#chapter-content, .chapter-c")
if content is None:
raise ValueError(f"Chapter text was not found at {chapter['url']}. The page may be blocked or its layout changed.")
for tag in content.select("script, style, iframe, ins, button, nav, .ads, .adsbygoogle, [hidden], h1, h2, h3"):
tag.decompose()
for tag in content.find_all(["p", "div", "br", "li"]):
tag.append(soup.new_string("\n\n"))
raw = content.get_text(separator="")
raw = _WATERMARK.sub("", raw).replace("\u200b", "")
paragraphs = []
leading = True
title_number = _CHAPTER_HEADING.match(chapter["title"])
for block in re.split(r"\n\s*\n", raw):
text = " ".join(block.split())
if not text:
continue
heading = _CHAPTER_HEADING.match(text)
# Some chapters repeat the title with a different subtitle. Match the
# chapter number so the existing workflow can speak the title just once.
same_heading = (heading and title_number and heading[1] == title_number[1])
if leading and (text.casefold() == chapter["title"].casefold()
or same_heading or _CREDITS.match(text)):
continue
leading = False
paragraphs.append(text)
text = "\n\n".join(paragraphs)
if not text:
raise ValueError(f"Chapter {chapter['title']} has no readable body text.")
return {"title": chapter["title"], "text": text}
def _load_chapter(chapter, cache_dir, refresh):
digest = hashlib.sha256(chapter["url"].encode()).hexdigest()
path = cache_dir / "chapters" / f"{digest}.json"
cached = _read_cache(path) if not refresh else None
if isinstance(cached, dict) and cached.get("text") and cached.get("title"):
return cached
result = _parse_chapter(_fetch_html(chapter["url"]), chapter)
_write_cache(path, result)
return result
class OmniVoiceWebNovelLoader:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"novel_url": ("STRING", {
"default": "https://novelfull.net/release-that-witch.html",
"tooltip": "NovelFull's novel URL. Downloads the chapter index once and caches selected chapter text locally.",
}),
"chapter_start": ("INT", {
"default": 1, "min": 1, "max": 99999, "step": 1,
"tooltip": "First chapter in reading order (1-indexed). Connect your incrementing chapter counter here.",
}),
"chapter_end": ("INT", {
"default": 1, "min": 1, "max": 99999, "step": 1,
"tooltip": "Last chapter, inclusive. Connect the same counter as chapter_start for one audio file per chapter.",
}),
"refresh_cache": ("BOOLEAN", {
"default": False,
"tooltip": "Re-download the chapter index and selected chapters. Leave off during batch generation.",
}),
}}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("text", "chapter_title", "chapter_list")
FUNCTION = "load_novel"
CATEGORY = "OmniVoice"
@classmethod
def IS_CHANGED(cls, novel_url, chapter_start, chapter_end, refresh_cache=False):
return float("nan") if refresh_cache else False
def load_novel(self, novel_url, chapter_start, chapter_end, refresh_cache=False):
url = _novel_url(novel_url)
cache_dir = _CACHE_DIR / hashlib.sha256(url.encode()).hexdigest()[:16]
chapters = _load_index(url, cache_dir, refresh_cache)
if not 1 <= chapter_start <= len(chapters):
raise ValueError(f"Chapter {chapter_start} is outside this novel's range (1–{len(chapters)}). Stop the queue or change the counter.")
end = max(chapter_start, min(chapter_end, len(chapters)))
selected = []
for chapter in chapters[chapter_start - 1:end]:
selected.append(_load_chapter(chapter, cache_dir, refresh_cache))
text = "\n\n---\n\n".join(ch["text"] for ch in selected)
title = selected[0]["title"]
chapter_list = "\n".join(f"{i}. {ch['title']}" for i, ch in enumerate(chapters, 1))
return text, title, chapter_list
+193
View File
@@ -0,0 +1,193 @@
from pathlib import Path
from unittest.mock import patch
from urllib.error import HTTPError
import pytest
from nodes import web_novel_loader as web
URL = "https://novelfull.net/example-book.html"
def index_page(numbers, pages=1):
links = "".join(
f'<li><a href="/example-book/chapter-{n}.html">Chapter {n}: Title {n}</a></li>'
for n in numbers
)
return (
'<a href="/example-book/chapter-999.html">Latest chapter</a>'
f'<div id="list-chapter" data-total-page="{pages}"><ul>{links}</ul></div>'
)
def chapter_page(n):
return (
f'<h1>Chapter {n}: Title {n}</h1><div id="chapter-content">'
f'<p>Chapter {n}: Different title</p><p>Translator: Someone</p>'
'<p>Editor: Someone</p>'
f'<p>Body {n} begins.</p><p>freёReadNovelFull.com</p>'
'<p>Body continues with <em>emphasis</em>.</p></div>'
'<nav>Next Chapter</nav><div>Comments</div>'
)
def fake_site():
return {
URL: index_page([1, 2], pages=2),
URL + "?page=2": index_page([3], pages=2),
**{f"https://novelfull.net/example-book/chapter-{n}.html": chapter_page(n)
for n in range(1, 4)},
}
@pytest.fixture
def cached_site(tmp_path, monkeypatch):
monkeypatch.setattr(web, "_CACHE_DIR", tmp_path)
monkeypatch.setattr(web.time, "sleep", lambda _: None)
with patch.object(web, "_fetch_html", side_effect=fake_site().__getitem__) as fetch:
yield fetch
def test_paginated_index_selects_only_requested_chapter(cached_site):
text, title, chapter_list = web.OmniVoiceWebNovelLoader().load_novel(URL, 3, 3)
assert text == "Body 3 begins.\n\nBody continues with emphasis."
assert title == "Chapter 3: Title 3"
assert chapter_list.splitlines() == [
"1. Chapter 1: Title 1", "2. Chapter 2: Title 2", "3. Chapter 3: Title 3",
]
assert [call.args[0] for call in cached_site.call_args_list] == [
URL, URL + "?page=2", "https://novelfull.net/example-book/chapter-3.html",
]
def test_cached_chapter_can_be_loaded_offline(cached_site):
loader = web.OmniVoiceWebNovelLoader()
expected = loader.load_novel(URL, 2, 2)
cached_site.reset_mock()
cached_site.side_effect = RuntimeError("Offline")
assert web.OmniVoiceWebNovelLoader().load_novel(URL, 2, 2) == expected
cached_site.assert_not_called()
def test_counter_fetches_next_chapter_without_reloading_index(cached_site):
loader = web.OmniVoiceWebNovelLoader()
loader.load_novel(URL, 1, 1)
cached_site.reset_mock()
text, title, _ = loader.load_novel(URL, 2, 2)
assert "Body 2 begins." in text
assert title == "Chapter 2: Title 2"
cached_site.assert_called_once_with("https://novelfull.net/example-book/chapter-2.html")
def test_existing_title_join_speaks_title_once(cached_site):
body, title, _ = web.OmniVoiceWebNovelLoader().load_novel(URL, 1, 1)
spoken = title + " \n\n" + body.replace("freёReadNovelFull.com\n", "").replace("\\", "")
assert spoken.count("Chapter 1") == 1
assert "Translator:" not in spoken
assert "Editor:" not in spoken
assert "NovelFull.com" not in spoken
assert "Next Chapter" not in spoken
assert "Comments" not in spoken
def test_range_and_end_below_start_match_epub_convention(cached_site):
loader = web.OmniVoiceWebNovelLoader()
text, _, _ = loader.load_novel(URL, 2, 999)
assert "Body 1" not in text
assert "Body 2" in text and "Body 3" in text
assert "\n\n---\n\n" in text
text, _, _ = loader.load_novel(URL, 2, 1)
assert "Body 2" in text and "Body 3" not in text
def test_counter_beyond_last_chapter_stops_instead_of_repeating(cached_site):
with pytest.raises(ValueError, match="outside this novel's range"):
web.OmniVoiceWebNovelLoader().load_novel(URL, 4, 4)
assert cached_site.call_count == 2 # index pages only
def test_refresh_redownloads_index_and_selected_chapter(cached_site):
loader = web.OmniVoiceWebNovelLoader()
loader.load_novel(URL, 1, 1)
cached_site.reset_mock()
loader.load_novel(URL, 1, 1, refresh_cache=True)
assert cached_site.call_count == 3
def test_old_pagination_layout_and_duplicate_links():
html = '''<div class="list-chapter">
<a href="/example-book/chapter-1.html">Chapter 1</a>
<a href="/example-book/chapter-1.html">Chapter 1</a>
<a href="/other-book/chapter-1.html">Other book</a>
<a href="https://other.example/example-book/chapter-2.html">Outside</a>
<a href="/example-book.html?page=3">Last</a></div>'''
chapters, pages = web._parse_index(html, URL)
assert pages == 3
assert chapters == [{"title": "Chapter 1", "url": "https://novelfull.net/example-book/chapter-1.html"}]
def test_repeated_index_page_is_not_cached(tmp_path, monkeypatch):
monkeypatch.setattr(web.time, "sleep", lambda _: None)
with patch.object(web, "_fetch_html", return_value=index_page([1], pages=2)):
with pytest.raises(ValueError, match="index is incomplete"):
web._load_index(URL, tmp_path, False)
assert not (tmp_path / "index.json").exists()
@pytest.mark.parametrize("url", [
"https://example.org/book.html", "file:///tmp/book.html",
"https://novelfull.net@example.org/book.html", "http://127.0.0.1/book.html",
"https://novelfull.net:9999/book.html",
"https://novelfull.net/example-book/chapter-1.html",
])
def test_invalid_urls_are_rejected_before_network_access(url):
with patch.object(web, "_fetch_html") as fetch:
with pytest.raises(ValueError):
web.OmniVoiceWebNovelLoader().load_novel(url, 1, 1)
fetch.assert_not_called()
def test_pasted_page_url_starts_index_at_first_page(cached_site):
text, _, _ = web.OmniVoiceWebNovelLoader().load_novel(URL + "?page=2#list", 1, 1)
assert "Body 1 begins." in text
assert cached_site.call_args_list[0].args == (URL,)
def test_missing_or_empty_body_does_not_narrate_page_navigation():
chapter = {"title": "Chapter 1", "url": URL}
for html in ["<h1>Blocked</h1><nav>Next Chapter</nav>",
'<div id="chapter-content"><p>Chapter 1</p><p>Translator: Someone</p></div>']:
with pytest.raises(ValueError):
web._parse_chapter(html, chapter)
def test_inline_breaks_ads_and_story_paragraphs_are_preserved():
html = '''<div id="chapter-content"><p>Chapter 1</p>
<p>First <strong>story</strong> paragraph.<br/>Second paragraph.</p>
<iframe>Advertisement</iframe><script>bad()</script><ins>Ad</ins>
<p>Translator: a character's job, inside the story.</p>
<p>Before freeReadNovelFull.com after.</p></div>'''
result = web._parse_chapter(html, {"title": "Chapter 1", "url": URL})
assert result["text"] == (
"First story paragraph.\n\nSecond paragraph.\n\n"
"Translator: a character's job, inside the story.\n\nBefore after."
)
def test_http_block_is_actionable_and_not_retried():
with patch.object(web, "urlopen", side_effect=HTTPError(URL, 403, "Forbidden", {}, None)) as request:
with pytest.raises(RuntimeError, match="HTTP 403"):
web._fetch_html(URL)
assert request.call_count == 1
def test_transient_http_error_is_retried(monkeypatch):
monkeypatch.setattr(web.time, "sleep", lambda _: None)
with patch.object(web, "urlopen") as request:
response = request.return_value.__enter__.return_value
response.geturl.return_value = URL
response.read.return_value = b"<html>Success</html>"
request.side_effect = [HTTPError(URL, 503, "Unavailable", {}, None), request.return_value]
assert web._fetch_html(URL) == "<html>Success</html>"
assert request.call_count == 2
File diff suppressed because it is too large. Load diff