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:
@@ -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()
|
||||
|
||||
@@ -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 & Magic <1>"
|
||||
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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user