feat: load NovelFull chapters directly for audiobooks
This commit is contained in:
1 parent
b4c1cb2955
commit
5a5505487c
6 files changed
+1799
-2
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user