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
+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