Download stories and build epubs with FanFicFare

FanFicFare adapters fetch metadata and chapters, and its epub writer
builds each book from the store. Environment variables configure the
app, and a default personal.ini is copied to the config folder on first
start.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 6c2574cf0f
commit c30622e3a0
7 changed files with 844 additions and 0 deletions
+18
View File
@@ -7,3 +7,21 @@ from pathlib import Path
_tmp = Path(tempfile.mkdtemp(prefix="bindery-tests-"))
for name, sub in [("CONFIG_DIR", "config"), ("INGEST_DIR", "ingest"), ("TMP_DIR", "tmp")]:
os.environ[name] = str(_tmp / sub)
os.environ["BINDERY_START_THREADS"] = "false"
os.environ["UPDATE_INTERVAL_HOURS"] = "0"
for name in (
"CWA_URL",
"CWA_USERNAME",
"CWA_PASSWORD",
"CWA_DB_PATH",
"CALIBRE_DB_PATH",
"AUTH_METHOD",
"BINDERY_API_KEY",
"URL_BASE",
"CWA_ALLOWED_USERS",
):
os.environ.pop(name, None)
from bindery import config # noqa: E402
config.prepare()
+155
View File
@@ -0,0 +1,155 @@
"""Build an epub from stored data without network access, then read it back."""
import base64
import datetime
import re
import zipfile
import pytest
from defusedxml import ElementTree
from bindery import config, fff
from bindery.rules import compile_rules, normalize_rules
URL = "https://www.royalroad.com/fiction/1234/test-story"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
IMG_URL = "https://www.royalroadcdn.com/public/img.png"
COVER = {"url": "https://www.royalroadcdn.com/public/covers/1234.png", "ext": "png", "mime": "image/png", "data": PNG}
def metahtml(url=URL):
adapter = fff.get_adapter(url)
story = adapter.story
story.setMetadata("title", "Test Story")
story.addToList("author", "Writer")
story.addToList("authorId", "42")
story.setMetadata("description", "<p>About the story</p>")
story.setMetadata("datePublished", datetime.datetime(2024, 1, 2))
story.setMetadata("dateUpdated", datetime.datetime(2024, 3, 4))
story.addToList("genre", "Fantasy")
return story.dump_html_metadata()
def chapters():
base = "https://www.royalroad.com/fiction/1234/test-story/chapter/{0}/c{0}"
return [
{"url": base.format(10), "title": "One", "html": '<div><p class="rnd1">Zorian wakes.</p></div>'},
{"url": base.format(11), "title": "Two", "html": f'<div><p>Picture</p><img src="{IMG_URL}"/></div>'},
{"url": base.format(15), "title": "Three", "html": "<div><p>Zorian again.</p></div>"},
]
def test_build_and_read_back(tmp_path):
rules = compile_rules(normalize_rules([{"pattern": "Zorian", "replacement": "Z"}]))
out = tmp_path / "book.epub"
images = [{"url": IMG_URL, "ext": "png", "mime": "image/png", "data": PNG}]
meta = fff.build_epub(URL, metahtml(), chapters(), images, None, rules, {"title": "Kept Title"}, out)
assert meta["title"] == "Kept Title"
assert meta["authors"] == ["Writer"]
assert meta["pubdate"].startswith("2024-01-02")
with zipfile.ZipFile(out) as z:
names = z.namelist()
text = "".join(z.read(n).decode("utf-8", "ignore") for n in names if n.endswith(".xhtml"))
opf = z.read("content.opf").decode()
assert "Z wakes." in text and "Zorian" not in text
assert any(n.startswith("OEBPS/images/ffdl-") and n.endswith(".png") for n in names)
assert re.search(r"<dc:title[^>]*>Kept Title</dc:title>", opf)
contents = fff.read_epub(out)
assert [c["title"] for c in contents.chapters] == ["One", "Two", "Three"]
assert [c["url"] for c in contents.chapters] == [c["url"] for c in chapters()]
assert IMG_URL in contents.chapters[1]["html"]
assert contents.images and contents.images[0]["url"] == IMG_URL
def test_a_title_with_entities_builds_and_imports_in_fanficfare_form(tmp_path):
# FanFicFare's form, as the site titles in the store have it.
title = "Folly &amp; Magic &lt;1&gt;"
out = tmp_path / "book.epub"
fff.build_epub(URL, metahtml(), [dict(chapters()[0], title=title)], [], None, [], {}, out)
with zipfile.ZipFile(out) as z:
for name in z.namelist():
if name.endswith((".xhtml", ".ncx", ".opf")):
ElementTree.fromstring(z.read(name))
# An import stores the same title as a download, so the next update finds no rename.
assert fff.read_epub(out).chapters[0]["title"] == title
assert fff.plain_title(title) == "Folly & Magic <1>"
assert fff.escape_title("Folly & Magic <1>") == title
@pytest.mark.parametrize(
("personal_ini", "expected"),
[
("", "coveronly"),
("[www.royalroad.com]\ninclude_images:false\n", "coveronly"),
("[defaults]\ninclude_images:true\n", "true"),
("[defaults]\ninclude_images:coveronly\n", "coveronly"),
],
)
def test_include_images_is_never_off(personal_ini, expected, tmp_path, monkeypatch):
path = tmp_path / "personal.ini"
path.write_text(personal_ini)
monkeypatch.setattr(config, "PERSONAL_INI", path)
assert fff.get_configuration(URL).getConfig("include_images") == expected
@pytest.mark.parametrize("personal_ini", ["", "[defaults]\ninclude_images:false\n"])
def test_the_cover_goes_in_the_epub_without_include_images(personal_ini, tmp_path, monkeypatch):
path = tmp_path / "personal.ini"
path.write_text(personal_ini)
monkeypatch.setattr(config, "PERSONAL_INI", path)
out = tmp_path / "book.epub"
fff.build_epub(URL, metahtml(), chapters(), [], COVER, [], {}, out)
with zipfile.ZipFile(out) as z:
names = z.namelist()
opf = z.read("content.opf").decode()
assert "OEBPS/cover.xhtml" in names
assert any(n.startswith("OEBPS/images/cover.") for n in names)
assert re.search(r'<meta name="cover" content="[^"]+"', opf)
def test_locked_fields_replace_site_metadata(tmp_path):
out = tmp_path / "book.epub"
overrides = {
"title": "Kept Title",
"authors": ["Pen Name", "Co Writer"],
"comments": "<p>Kept blurb</p>",
"series": ["Kept Saga", 3.0],
"pubdate": "2019-05-06T00:00:00+00:00",
"languages": "de",
"tags": ["mine", "reread"],
}
meta = fff.build_epub(URL, metahtml(), chapters(), [], None, [], overrides, out)
assert meta["authors"] == ["Pen Name", "Co Writer"]
assert meta["tags"] == ["mine", "reread"]
with zipfile.ZipFile(out) as z:
opf = z.read("content.opf").decode()
assert re.findall(r"<dc:creator[^>]*>([^<]*)</dc:creator>", opf) == ["Pen Name", "Co Writer"]
assert re.findall(r"<dc:subject>([^<]*)</dc:subject>", opf) == ["mine", "reread"]
assert 'name="calibre:series" content="Kept Saga"' in opf
assert 'name="calibre:series_index" content="3.00"' in opf
assert "<dc:language>de</dc:language>" in opf
assert "2019-05-06" in opf
assert "Kept blurb" in opf
def test_unlocked_fields_keep_site_metadata(tmp_path):
out = tmp_path / "book.epub"
meta = fff.build_epub(URL, metahtml(), chapters(), [], None, [], {}, out)
assert meta["title"] == "Test Story"
assert meta["authors"] == ["Writer"]
assert "Fantasy" in meta["tags"]
def test_supported_sites():
assert fff.is_supported_site("www.royalroad.com")
assert fff.is_supported_site("WWW.RoyalRoad.com ")
assert not fff.is_supported_site("example.com")
def test_normalize_url():
assert fff.normalize_url(URL) == ("https://www.royalroad.com/fiction/1234", "www.royalroad.com")
assert fff.normalize_url("https://example.com/nothing") is None
+112
View File
@@ -0,0 +1,112 @@
"""Reading the configuration from environment variables."""
import importlib
from pathlib import Path
import pytest
from bindery import config
@pytest.fixture
def reload_config(monkeypatch):
"""Load the config module again with a changed environment, and restore it afterwards."""
yield lambda: importlib.reload(config)
monkeypatch.undo()
importlib.reload(config)
def test_bool(monkeypatch):
for value in ("true", "Yes", " 1 ", "on", "y"):
monkeypatch.setenv("X_TEST", value)
assert config._bool("X_TEST")
for value in ("false", "0", "no", ""):
monkeypatch.setenv("X_TEST", value)
assert not config._bool("X_TEST")
monkeypatch.delenv("X_TEST")
assert config._bool("X_TEST", "true")
def test_list(monkeypatch):
monkeypatch.setenv("X_TEST", " title, tags ,,series ")
assert config._list("X_TEST") == ["title", "tags", "series"]
monkeypatch.delenv("X_TEST")
assert config._list("X_TEST") == []
assert config._list("X_TEST", "title") == ["title"]
def test_relative_paths_become_absolute(monkeypatch, tmp_path, reload_config):
monkeypatch.chdir(tmp_path)
for name, value in [("CONFIG_DIR", "dev/config"), ("INGEST_DIR", "dev/ingest"), ("TMP_DIR", "dev/tmp")]:
monkeypatch.setenv(name, value)
monkeypatch.setenv("CWA_DB_PATH", "cwa/app.db")
monkeypatch.setenv("CALIBRE_DB_PATH", "library/metadata.db")
reload_config()
assert config.CONFIG_DIR == Path.cwd() / "dev/config"
assert config.INGEST_DIR == Path.cwd() / "dev/ingest"
assert config.TMP_DIR == Path.cwd() / "dev/tmp"
assert config.CWA_DB_PATH == Path.cwd() / "cwa/app.db"
assert config.CALIBRE_DB_PATH == Path.cwd() / "library/metadata.db"
@pytest.mark.parametrize(
("value", "expected"),
[
("", ""),
("/", ""),
("bindery", "/bindery"),
("/bindery/", "/bindery"),
(" /a/b ", "/a/b"),
],
)
def test_base_path(value, expected):
assert config._base_path(value) == expected
def test_values_from_the_environment(monkeypatch, reload_config):
monkeypatch.setenv("URL_BASE", "bindery/")
monkeypatch.setenv("AUTH_METHOD", " CWA ")
monkeypatch.setenv("UPDATE_INTERVAL_HOURS", "1.5")
monkeypatch.setenv("DEFAULT_LOCKED_FIELDS", "title, authors")
monkeypatch.setenv("CWA_ALLOWED_USERS", "alice,bob")
monkeypatch.setenv("CONFIG_DIR", "/data/bindery")
monkeypatch.setenv("TRUST_PROXY", "yes")
monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ")
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
reload_config()
assert config.URL_BASE == "/bindery"
assert config.AUTH_METHOD == "cwa"
assert config.UPDATE_INTERVAL_HOURS == 1.5
assert config.DEFAULT_LOCKED_FIELDS == ["title", "authors"]
assert config.CWA_ALLOWED_USERS == ["alice", "bob"]
assert str(config.DB_PATH) == "/data/bindery/bindery.db"
assert config.TRUST_PROXY is True
assert str(config.CWA_DB_PATH) == "/cwa/app.db"
assert str(config.CALIBRE_DB_PATH) == "/library/metadata.db"
def test_defaults(monkeypatch, reload_config):
for name in (
"URL_BASE",
"AUTH_METHOD",
"UPDATE_INTERVAL_HOURS",
"DEFAULT_LOCKED_FIELDS",
"LOG_LEVEL",
"PROXY_AUTH_USER_HEADER",
"SESSION_COOKIE_SECURE",
"TRUST_PROXY",
"CWA_DB_PATH",
"CALIBRE_DB_PATH",
):
monkeypatch.delenv(name, raising=False)
reload_config()
assert config.URL_BASE == ""
assert config.AUTH_METHOD == "none"
assert config.UPDATE_INTERVAL_HOURS == 24
assert config.DEFAULT_LOCKED_FIELDS == ["title"]
assert config.LOG_LEVEL == "INFO"
assert config.PROXY_AUTH_USER_HEADER == "X-Remote-User"
assert config.SESSION_COOKIE_SECURE is False
assert config.TRUST_PROXY is False
assert config.CWA_DB_PATH is None
assert config.CALIBRE_DB_PATH is None
+108
View File
@@ -0,0 +1,108 @@
"""Exercise the FanFicFare boundary without requests to a story site."""
import zipfile
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from test_build import IMG_URL, PNG, URL, chapters, metahtml
from bindery import config, fff
@pytest.mark.parametrize("cover", [None, {}, {"url": IMG_URL, "ext": "png", "mime": "image/png", "data": PNG}])
def test_metadata_snapshot_from_adapter(monkeypatch, cover):
adapter = fff.get_adapter(URL)
adapter.setStoryMetadata(metahtml())
adapter.chapterUrls = chapters()
adapter.story.img_store.cover = cover
fetch = Mock(return_value=adapter.story)
monkeypatch.setattr(adapter, "getStoryMetadataOnly", fetch)
snap = fff.fetch_story(adapter)
fetch.assert_called_once_with(get_cover=True)
assert snap.title == "Test Story"
assert snap.author == "Writer"
assert snap.site == "www.royalroad.com"
assert snap.chapters == [{"url": c["url"], "title": c["title"]} for c in chapters()]
assert snap.calibre["authors"] == ["Writer"]
assert snap.cover == (cover or None)
assert "Test Story" in snap.metahtml
@pytest.mark.parametrize(
"error,message",
[
(fff.exceptions.StoryDoesNotExist(URL), "StoryDoesNotExist"),
(fff.exceptions.AccessDenied("private"), "AccessDenied"),
(fff.exceptions.FailedToLogin(URL, "reader"), "FailedToLogin"),
(fff.exceptions.AdultCheckRequired(URL), "is_adult:true"),
],
)
def test_adapter_errors_become_actionable_story_errors(error, message):
adapter = SimpleNamespace(getStoryMetadataOnly=Mock(side_effect=error))
with pytest.raises(fff.StoryError, match=message):
fff.fetch_story(adapter)
def test_downloaded_chapter_restores_original_image_urls():
html = '<p>Text</p><img src="images/1.png" longdesc="https://images.test/1.png">'
adapter = SimpleNamespace(getChapterTextNum=Mock(return_value=html))
result = fff.fetch_chapter(adapter, "chapter", 3)
adapter.getChapterTextNum.assert_called_once_with("chapter", 3)
assert 'src="https://images.test/1.png"' in result
for src in ["data:image/png;base64,abc", "failedtoload", "https://images.test/direct.png"]:
markup = f'<img src="{src}"' + (' longdesc="original"' if src != "https://images.test/direct.png" else "") + ">"
assert f'src="{src}"' in fff.canonical_html(markup)
assert fff.canonical_html("<p>plain</p>") == "<p>plain</p>"
def test_collected_images_skip_failed_and_empty_downloads():
image = {"url": "image", "ext": "png", "mime": "image/png", "data": PNG}
adapter = SimpleNamespace(
story=SimpleNamespace(
img_store=SimpleNamespace(
infos=[{**image, "newsrc": "images/1.png"}, {**image, "data": b""}, {**image, "newsrc": "failedtoload"}]
)
)
)
assert fff.collected_images(adapter) == [image]
def test_personal_ini_overrides_and_invalid_input(monkeypatch, tmp_path):
ini = tmp_path / "personal.ini"
ini.write_text("[overrides]\nis_adult: true\n", encoding="utf-8")
monkeypatch.setattr(config, "PERSONAL_INI", ini)
configuration = fff.get_configuration(URL)
assert configuration.getConfig("is_adult")
for text in ["[defaults]\nbroken line\n", "setting: before any section"]:
with pytest.raises(ValueError, match="cannot read"):
fff.check_personal_ini(text)
with pytest.raises(fff.StoryError, match="does not support"):
fff.get_configuration("https://unsupported.example/story")
def test_adapter_rejects_invalid_story_url(monkeypatch):
monkeypatch.setattr(
fff.adapters, "getAdapter", Mock(side_effect=fff.exceptions.InvalidStoryURL(URL, "site", "example"))
)
with pytest.raises(fff.StoryError):
fff.get_adapter(URL)
def test_normalization_errors_are_not_site_requests(monkeypatch):
monkeypatch.setattr(fff.adapters, "getNormalStoryURLSite", Mock(side_effect=ValueError("bad URL")))
fff.normalize_url.cache_clear()
try:
assert fff.normalize_url("bad-input") is None
finally:
fff.normalize_url.cache_clear()
def test_empty_chapters_fail_and_stored_cover_is_built(tmp_path):
with pytest.raises(fff.StoryError, match="no chapters"):
fff.build_epub(URL, metahtml(), [], [], None, [], {}, tmp_path / "empty.epub")
cover = {"url": IMG_URL, "ext": "png", "mime": "image/png", "data": PNG}
out = tmp_path / "cover.epub"
fff.build_epub(URL, metahtml(), chapters()[:1], [], cover, [], {}, out)
with zipfile.ZipFile(out) as book:
assert any(book.read(n) == PNG for n in book.namelist() if n.endswith(".png"))