novel-downloader 1.4.4__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/__init__.py +2 -2
- novel_downloader/cli/config.py +1 -83
- novel_downloader/cli/download.py +4 -5
- novel_downloader/cli/export.py +4 -1
- novel_downloader/cli/main.py +2 -0
- novel_downloader/cli/search.py +123 -0
- novel_downloader/config/__init__.py +3 -10
- novel_downloader/config/adapter.py +190 -54
- novel_downloader/config/loader.py +2 -3
- novel_downloader/core/__init__.py +13 -13
- novel_downloader/core/downloaders/__init__.py +10 -11
- novel_downloader/core/downloaders/base.py +152 -26
- novel_downloader/core/downloaders/biquge.py +5 -1
- novel_downloader/core/downloaders/common.py +157 -378
- novel_downloader/core/downloaders/esjzone.py +5 -1
- novel_downloader/core/downloaders/linovelib.py +5 -1
- novel_downloader/core/downloaders/qianbi.py +291 -4
- novel_downloader/core/downloaders/qidian.py +199 -285
- novel_downloader/core/downloaders/registry.py +67 -0
- novel_downloader/core/downloaders/sfacg.py +5 -1
- novel_downloader/core/downloaders/yamibo.py +5 -1
- novel_downloader/core/exporters/__init__.py +10 -11
- novel_downloader/core/exporters/base.py +87 -7
- novel_downloader/core/exporters/biquge.py +5 -8
- novel_downloader/core/exporters/common/__init__.py +2 -2
- novel_downloader/core/exporters/common/epub.py +82 -166
- novel_downloader/core/exporters/common/main_exporter.py +0 -60
- novel_downloader/core/exporters/common/txt.py +82 -83
- novel_downloader/core/exporters/epub_util.py +157 -1330
- novel_downloader/core/exporters/esjzone.py +5 -8
- novel_downloader/core/exporters/linovelib/__init__.py +2 -2
- novel_downloader/core/exporters/linovelib/epub.py +157 -212
- novel_downloader/core/exporters/linovelib/main_exporter.py +2 -59
- novel_downloader/core/exporters/linovelib/txt.py +67 -63
- novel_downloader/core/exporters/qianbi.py +5 -8
- novel_downloader/core/exporters/qidian.py +14 -4
- novel_downloader/core/exporters/registry.py +53 -0
- novel_downloader/core/exporters/sfacg.py +5 -8
- novel_downloader/core/exporters/txt_util.py +67 -0
- novel_downloader/core/exporters/yamibo.py +5 -8
- novel_downloader/core/fetchers/__init__.py +19 -24
- novel_downloader/core/fetchers/base/__init__.py +3 -3
- novel_downloader/core/fetchers/base/browser.py +23 -4
- novel_downloader/core/fetchers/base/session.py +30 -5
- novel_downloader/core/fetchers/biquge/__init__.py +3 -3
- novel_downloader/core/fetchers/biquge/browser.py +5 -0
- novel_downloader/core/fetchers/biquge/session.py +6 -1
- novel_downloader/core/fetchers/esjzone/__init__.py +3 -3
- novel_downloader/core/fetchers/esjzone/browser.py +5 -0
- novel_downloader/core/fetchers/esjzone/session.py +6 -1
- novel_downloader/core/fetchers/linovelib/__init__.py +3 -3
- novel_downloader/core/fetchers/linovelib/browser.py +6 -1
- novel_downloader/core/fetchers/linovelib/session.py +6 -1
- novel_downloader/core/fetchers/qianbi/__init__.py +3 -3
- novel_downloader/core/fetchers/qianbi/browser.py +5 -0
- novel_downloader/core/fetchers/qianbi/session.py +5 -0
- novel_downloader/core/fetchers/qidian/__init__.py +3 -3
- novel_downloader/core/fetchers/qidian/browser.py +12 -4
- novel_downloader/core/fetchers/qidian/session.py +11 -3
- novel_downloader/core/fetchers/registry.py +71 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +3 -3
- novel_downloader/core/fetchers/sfacg/browser.py +5 -0
- novel_downloader/core/fetchers/sfacg/session.py +5 -0
- novel_downloader/core/fetchers/yamibo/__init__.py +3 -3
- novel_downloader/core/fetchers/yamibo/browser.py +5 -0
- novel_downloader/core/fetchers/yamibo/session.py +6 -1
- novel_downloader/core/interfaces/__init__.py +7 -5
- novel_downloader/core/interfaces/searcher.py +18 -0
- novel_downloader/core/parsers/__init__.py +10 -11
- novel_downloader/core/parsers/{biquge/main_parser.py → biquge.py} +7 -2
- novel_downloader/core/parsers/{esjzone/main_parser.py → esjzone.py} +7 -2
- novel_downloader/core/parsers/{linovelib/main_parser.py → linovelib.py} +7 -2
- novel_downloader/core/parsers/{qianbi/main_parser.py → qianbi.py} +7 -2
- novel_downloader/core/parsers/qidian/__init__.py +2 -2
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +23 -21
- novel_downloader/core/parsers/qidian/chapter_normal.py +1 -1
- novel_downloader/core/parsers/qidian/main_parser.py +10 -21
- novel_downloader/core/parsers/qidian/utils/__init__.py +11 -11
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +5 -6
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +2 -2
- novel_downloader/core/parsers/registry.py +68 -0
- novel_downloader/core/parsers/{sfacg/main_parser.py → sfacg.py} +7 -2
- novel_downloader/core/parsers/{yamibo/main_parser.py → yamibo.py} +7 -2
- novel_downloader/core/searchers/__init__.py +20 -0
- novel_downloader/core/searchers/base.py +92 -0
- novel_downloader/core/searchers/biquge.py +83 -0
- novel_downloader/core/searchers/esjzone.py +84 -0
- novel_downloader/core/searchers/qianbi.py +131 -0
- novel_downloader/core/searchers/qidian.py +87 -0
- novel_downloader/core/searchers/registry.py +63 -0
- novel_downloader/locales/en.json +12 -4
- novel_downloader/locales/zh.json +12 -4
- novel_downloader/models/__init__.py +4 -30
- novel_downloader/models/config.py +12 -6
- novel_downloader/models/search.py +16 -0
- novel_downloader/models/types.py +0 -2
- novel_downloader/resources/config/settings.toml +31 -4
- novel_downloader/resources/css_styles/intro.css +83 -0
- novel_downloader/resources/css_styles/main.css +30 -89
- novel_downloader/utils/__init__.py +52 -0
- novel_downloader/utils/chapter_storage.py +244 -224
- novel_downloader/utils/constants.py +1 -21
- novel_downloader/utils/epub/__init__.py +34 -0
- novel_downloader/utils/epub/builder.py +377 -0
- novel_downloader/utils/epub/constants.py +77 -0
- novel_downloader/utils/epub/documents.py +403 -0
- novel_downloader/utils/epub/models.py +134 -0
- novel_downloader/utils/epub/utils.py +212 -0
- novel_downloader/utils/file_utils/__init__.py +10 -14
- novel_downloader/utils/file_utils/io.py +20 -51
- novel_downloader/utils/file_utils/normalize.py +2 -2
- novel_downloader/utils/file_utils/sanitize.py +2 -3
- novel_downloader/utils/fontocr/__init__.py +5 -5
- novel_downloader/utils/{hash_store.py → fontocr/hash_store.py} +4 -3
- novel_downloader/utils/{hash_utils.py → fontocr/hash_utils.py} +2 -2
- novel_downloader/utils/fontocr/ocr_v1.py +13 -1
- novel_downloader/utils/fontocr/ocr_v2.py +13 -1
- novel_downloader/utils/fontocr/ocr_v3.py +744 -0
- novel_downloader/utils/i18n.py +2 -0
- novel_downloader/utils/logger.py +2 -0
- novel_downloader/utils/network.py +110 -251
- novel_downloader/utils/state.py +1 -0
- novel_downloader/utils/text_utils/__init__.py +18 -17
- novel_downloader/utils/text_utils/diff_display.py +4 -5
- novel_downloader/utils/text_utils/numeric_conversion.py +253 -0
- novel_downloader/utils/text_utils/text_cleaner.py +179 -0
- novel_downloader/utils/text_utils/truncate_utils.py +62 -0
- novel_downloader/utils/time_utils/__init__.py +3 -3
- novel_downloader/utils/time_utils/datetime_utils.py +4 -5
- novel_downloader/utils/time_utils/sleep_utils.py +2 -3
- {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/METADATA +2 -2
- novel_downloader-1.5.0.dist-info/RECORD +164 -0
- novel_downloader/config/site_rules.py +0 -94
- novel_downloader/core/factory/__init__.py +0 -20
- novel_downloader/core/factory/downloader.py +0 -73
- novel_downloader/core/factory/exporter.py +0 -58
- novel_downloader/core/factory/fetcher.py +0 -96
- novel_downloader/core/factory/parser.py +0 -86
- novel_downloader/core/fetchers/common/__init__.py +0 -14
- novel_downloader/core/fetchers/common/browser.py +0 -79
- novel_downloader/core/fetchers/common/session.py +0 -79
- novel_downloader/core/parsers/biquge/__init__.py +0 -10
- novel_downloader/core/parsers/common/__init__.py +0 -13
- novel_downloader/core/parsers/common/helper.py +0 -323
- novel_downloader/core/parsers/common/main_parser.py +0 -106
- novel_downloader/core/parsers/esjzone/__init__.py +0 -10
- novel_downloader/core/parsers/linovelib/__init__.py +0 -10
- novel_downloader/core/parsers/qianbi/__init__.py +0 -10
- novel_downloader/core/parsers/sfacg/__init__.py +0 -10
- novel_downloader/core/parsers/yamibo/__init__.py +0 -10
- novel_downloader/models/browser.py +0 -21
- novel_downloader/models/site_rules.py +0 -99
- novel_downloader/models/tasks.py +0 -33
- novel_downloader/resources/css_styles/volume-intro.css +0 -56
- novel_downloader/resources/json/replace_word_map.json +0 -4
- novel_downloader/resources/text/blacklist.txt +0 -22
- novel_downloader/utils/text_utils/chapter_formatting.py +0 -46
- novel_downloader/utils/text_utils/font_mapping.py +0 -28
- novel_downloader/utils/text_utils/text_cleaning.py +0 -107
- novel_downloader-1.4.4.dist-info/RECORD +0 -165
- {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/WHEEL +0 -0
- {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,403 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.utils.epub.documents
|
4
|
+
-------------------------------------
|
5
|
+
|
6
|
+
Defines the classes that render EPUB navigation and packaging documents:
|
7
|
+
- NavDocument: builds the XHTML nav.xhtml (EPUB 3)
|
8
|
+
- NCXDocument: builds the NCX XML navigation map (EPUB 2)
|
9
|
+
- OpfDocument: builds the content.opf package document
|
10
|
+
"""
|
11
|
+
|
12
|
+
from collections.abc import Sequence
|
13
|
+
from dataclasses import dataclass, field
|
14
|
+
from datetime import UTC, datetime
|
15
|
+
|
16
|
+
from lxml import etree
|
17
|
+
from lxml.builder import ElementMaker
|
18
|
+
|
19
|
+
from .constants import (
|
20
|
+
DC_NS,
|
21
|
+
EPUB_NS,
|
22
|
+
NCX_NS,
|
23
|
+
OPF_NS,
|
24
|
+
OPF_PKG_ATTRIB,
|
25
|
+
PRETTY_PRINT_FLAG,
|
26
|
+
XHTML_NS,
|
27
|
+
XML_NS,
|
28
|
+
)
|
29
|
+
from .models import (
|
30
|
+
ChapterEntry,
|
31
|
+
EpubResource,
|
32
|
+
ManifestEntry,
|
33
|
+
NavPoint,
|
34
|
+
SpineEntry,
|
35
|
+
VolumeEntry,
|
36
|
+
)
|
37
|
+
|
38
|
+
NAV = ElementMaker(
|
39
|
+
namespace=XHTML_NS,
|
40
|
+
nsmap={None: XHTML_NS, "epub": EPUB_NS},
|
41
|
+
)
|
42
|
+
NCX = ElementMaker(namespace=NCX_NS, nsmap={None: NCX_NS})
|
43
|
+
PKG = ElementMaker(
|
44
|
+
namespace=OPF_NS,
|
45
|
+
nsmap={
|
46
|
+
None: OPF_NS,
|
47
|
+
"dc": DC_NS,
|
48
|
+
"opf": OPF_NS,
|
49
|
+
},
|
50
|
+
)
|
51
|
+
DC = ElementMaker(namespace=DC_NS)
|
52
|
+
|
53
|
+
|
54
|
+
@dataclass
|
55
|
+
class NavDocument(EpubResource):
|
56
|
+
title: str = "未命名"
|
57
|
+
language: str = "zh-CN"
|
58
|
+
id: str = "nav"
|
59
|
+
filename: str = "nav.xhtml"
|
60
|
+
media_type: str = field(init=False, default="application/xhtml+xml")
|
61
|
+
content_items: list[ChapterEntry | VolumeEntry] = field(default_factory=list)
|
62
|
+
|
63
|
+
def add_chapter(
|
64
|
+
self,
|
65
|
+
id: str,
|
66
|
+
label: str,
|
67
|
+
src: str,
|
68
|
+
) -> None:
|
69
|
+
"""
|
70
|
+
Add a top-level chapter entry to the navigation.
|
71
|
+
|
72
|
+
:param id: The unique ID for the chapter.
|
73
|
+
:param label: The display title for the chapter.
|
74
|
+
:param src: The href target for the chapter's XHTML file.
|
75
|
+
"""
|
76
|
+
self.content_items.append(ChapterEntry(id=id, label=label, src=src))
|
77
|
+
|
78
|
+
def add_volume(
|
79
|
+
self,
|
80
|
+
id: str,
|
81
|
+
label: str,
|
82
|
+
src: str,
|
83
|
+
chapters: list[ChapterEntry],
|
84
|
+
) -> None:
|
85
|
+
"""
|
86
|
+
Add a volume entry with nested chapters to the navigation.
|
87
|
+
|
88
|
+
:param id: The unique ID for the volume.
|
89
|
+
:param label: The display title for the volume.
|
90
|
+
:param src: The href target for the volume's intro XHTML file.
|
91
|
+
:param chapters: A list of chapter entries under this volume.
|
92
|
+
"""
|
93
|
+
self.content_items.append(
|
94
|
+
VolumeEntry(id=id, label=label, src=src, chapters=chapters)
|
95
|
+
)
|
96
|
+
|
97
|
+
def to_xhtml(self) -> str:
|
98
|
+
"""
|
99
|
+
Generate the XHTML content for nav.xhtml based on the NavDocument.
|
100
|
+
|
101
|
+
:return: A string containing the full XHTML for nav.xhtml.
|
102
|
+
"""
|
103
|
+
# build the root <html> with both lang attributes
|
104
|
+
html_el = NAV.html(
|
105
|
+
# head/title
|
106
|
+
NAV.head(NAV.title(self.title)),
|
107
|
+
# body/nav/ol subtree
|
108
|
+
NAV.body(
|
109
|
+
NAV.nav(
|
110
|
+
NAV.h2(self.title),
|
111
|
+
NAV.ol(*self._render_items(self.content_items)),
|
112
|
+
# namespaced + regular attributes
|
113
|
+
**{
|
114
|
+
f"{{{EPUB_NS}}}type": "toc",
|
115
|
+
"id": self.id,
|
116
|
+
"role": "doc-toc",
|
117
|
+
},
|
118
|
+
)
|
119
|
+
),
|
120
|
+
# html attributes
|
121
|
+
lang=self.language,
|
122
|
+
**{f"{{{XML_NS}}}lang": self.language},
|
123
|
+
)
|
124
|
+
|
125
|
+
xml_bytes = etree.tostring(
|
126
|
+
html_el,
|
127
|
+
xml_declaration=True,
|
128
|
+
encoding="utf-8",
|
129
|
+
pretty_print=PRETTY_PRINT_FLAG,
|
130
|
+
doctype="<!DOCTYPE html>",
|
131
|
+
)
|
132
|
+
xml_string: str = xml_bytes.decode("utf-8")
|
133
|
+
return xml_string
|
134
|
+
|
135
|
+
@classmethod
|
136
|
+
def _render_items(
|
137
|
+
cls,
|
138
|
+
items: Sequence[ChapterEntry | VolumeEntry],
|
139
|
+
) -> list[etree._Element]:
|
140
|
+
"""
|
141
|
+
Recursively build <li> elements (and nested <ol>) for each TOC entry.
|
142
|
+
"""
|
143
|
+
elements: list[etree._Element] = []
|
144
|
+
for item in items:
|
145
|
+
if isinstance(item, VolumeEntry) and item.chapters:
|
146
|
+
li = NAV.li(NAV.a(item.label, href=item.src))
|
147
|
+
li.append(NAV.ol(*cls._render_items(item.chapters)))
|
148
|
+
else:
|
149
|
+
li = NAV.li(NAV.a(item.label, href=item.src))
|
150
|
+
elements.append(li)
|
151
|
+
return elements
|
152
|
+
|
153
|
+
|
154
|
+
@dataclass
|
155
|
+
class NCXDocument(EpubResource):
|
156
|
+
title: str = "未命名"
|
157
|
+
uid: str = ""
|
158
|
+
id: str = "ncx"
|
159
|
+
filename: str = "toc.ncx"
|
160
|
+
media_type: str = field(init=False, default="application/x-dtbncx+xml")
|
161
|
+
nav_points: list[NavPoint] = field(default_factory=list)
|
162
|
+
|
163
|
+
def add_chapter(
|
164
|
+
self,
|
165
|
+
id: str,
|
166
|
+
label: str,
|
167
|
+
src: str,
|
168
|
+
) -> None:
|
169
|
+
"""
|
170
|
+
Add a single flat chapter entry to the NCX nav map.
|
171
|
+
"""
|
172
|
+
self.nav_points.append(NavPoint(id=id, label=label, src=src))
|
173
|
+
|
174
|
+
def add_volume(
|
175
|
+
self,
|
176
|
+
id: str,
|
177
|
+
label: str,
|
178
|
+
src: str,
|
179
|
+
chapters: list[ChapterEntry],
|
180
|
+
) -> None:
|
181
|
+
"""
|
182
|
+
Add a volume with nested chapters to the NCX nav map.
|
183
|
+
"""
|
184
|
+
children = [NavPoint(id=c.id, label=c.label, src=c.src) for c in chapters]
|
185
|
+
self.nav_points.append(NavPoint(id=id, label=label, src=src, children=children))
|
186
|
+
|
187
|
+
def to_xml(self) -> str:
|
188
|
+
"""
|
189
|
+
Generate the XML content for toc.ncx used in EPUB 2 navigation.
|
190
|
+
|
191
|
+
:return: A string containing the full NCX XML document.
|
192
|
+
"""
|
193
|
+
root = NCX.ncx(version="2005-1")
|
194
|
+
head = NCX.head(
|
195
|
+
NCX.meta(name="dtb:uid", content=self.uid),
|
196
|
+
NCX.meta(name="dtb:depth", content=str(self._depth(self.nav_points))),
|
197
|
+
NCX.meta(name="dtb:totalPageCount", content="0"),
|
198
|
+
NCX.meta(name="dtb:maxPageNumber", content="0"),
|
199
|
+
)
|
200
|
+
root.append(head)
|
201
|
+
root.append(NCX.docTitle(NCX.text(self.title)))
|
202
|
+
|
203
|
+
navMap = NCX.navMap()
|
204
|
+
root.append(navMap)
|
205
|
+
|
206
|
+
self._render_navpoints(navMap, self.nav_points, start=1)
|
207
|
+
|
208
|
+
xml_bytes = etree.tostring(
|
209
|
+
root,
|
210
|
+
xml_declaration=True,
|
211
|
+
encoding="utf-8",
|
212
|
+
pretty_print=PRETTY_PRINT_FLAG,
|
213
|
+
)
|
214
|
+
xml_string: str = xml_bytes.decode("utf-8")
|
215
|
+
return xml_string
|
216
|
+
|
217
|
+
@classmethod
|
218
|
+
def _depth(cls, points: list[NavPoint]) -> int:
|
219
|
+
if not points:
|
220
|
+
return 0
|
221
|
+
return 1 + max(cls._depth(child.children) for child in points)
|
222
|
+
|
223
|
+
@classmethod
|
224
|
+
def _render_navpoints(
|
225
|
+
cls,
|
226
|
+
parent: etree._Element,
|
227
|
+
points: list[NavPoint],
|
228
|
+
start: int,
|
229
|
+
) -> int:
|
230
|
+
"""
|
231
|
+
Recursively append <navPoint> elements under `parent`,
|
232
|
+
assigning playOrder starting from `start`.
|
233
|
+
Returns the next unused playOrder.
|
234
|
+
"""
|
235
|
+
play = start
|
236
|
+
for pt in points:
|
237
|
+
np = etree.SubElement(
|
238
|
+
parent,
|
239
|
+
"navPoint",
|
240
|
+
id=pt.id,
|
241
|
+
playOrder=str(play),
|
242
|
+
)
|
243
|
+
play += 1
|
244
|
+
navLabel = etree.SubElement(np, "navLabel")
|
245
|
+
lbl_text = etree.SubElement(navLabel, "text")
|
246
|
+
lbl_text.text = pt.label
|
247
|
+
etree.SubElement(np, "content", src=pt.src)
|
248
|
+
play = cls._render_navpoints(np, pt.children, play)
|
249
|
+
return play
|
250
|
+
|
251
|
+
|
252
|
+
@dataclass
|
253
|
+
class OpfDocument(EpubResource):
|
254
|
+
# metadata fields
|
255
|
+
title: str = ""
|
256
|
+
author: str = ""
|
257
|
+
description: str = ""
|
258
|
+
uid: str = ""
|
259
|
+
subject: list[str] = field(default_factory=list)
|
260
|
+
language: str = "zh-CN"
|
261
|
+
|
262
|
+
# resource identity
|
263
|
+
id: str = "opf"
|
264
|
+
filename: str = "content.opf"
|
265
|
+
media_type: str = field(init=False, default="application/oebps-package+xml")
|
266
|
+
|
267
|
+
# internal state
|
268
|
+
include_cover: bool = False
|
269
|
+
manifest: list[ManifestEntry] = field(default_factory=list)
|
270
|
+
spine: list[SpineEntry] = field(default_factory=list)
|
271
|
+
_cover_item: ManifestEntry | None = field(init=False, default=None)
|
272
|
+
_toc_item: ManifestEntry | None = field(init=False, default=None)
|
273
|
+
_cover_doc: ManifestEntry | None = field(init=False, default=None)
|
274
|
+
|
275
|
+
def add_manifest_item(
|
276
|
+
self,
|
277
|
+
id: str,
|
278
|
+
href: str,
|
279
|
+
media_type: str,
|
280
|
+
properties: str | None = None,
|
281
|
+
) -> None:
|
282
|
+
entry = ManifestEntry(
|
283
|
+
id=id,
|
284
|
+
href=href,
|
285
|
+
media_type=media_type,
|
286
|
+
properties=properties,
|
287
|
+
)
|
288
|
+
self.manifest.append(entry)
|
289
|
+
|
290
|
+
if properties == "cover-image":
|
291
|
+
self._cover_item = entry
|
292
|
+
if media_type == "application/x-dtbncx+xml":
|
293
|
+
self._toc_item = entry
|
294
|
+
if id == "cover":
|
295
|
+
self._cover_doc = entry
|
296
|
+
|
297
|
+
def add_spine_item(
|
298
|
+
self,
|
299
|
+
idref: str,
|
300
|
+
properties: str | None = None,
|
301
|
+
) -> None:
|
302
|
+
self.spine.append(SpineEntry(idref=idref, properties=properties))
|
303
|
+
|
304
|
+
def set_subject(self, subject: list[str]) -> None:
|
305
|
+
self.subject = subject
|
306
|
+
|
307
|
+
def to_xml(self) -> str:
|
308
|
+
"""
|
309
|
+
Generate the content.opf XML, which defines metadata, manifest, and spine.
|
310
|
+
|
311
|
+
This function outputs a complete OPF package document that includes:
|
312
|
+
- <metadata>: title, author, language, identifiers, etc.
|
313
|
+
- <manifest>: all resource entries
|
314
|
+
- <spine>: the reading order of the content
|
315
|
+
- <guide>: optional references like cover page
|
316
|
+
|
317
|
+
:return: A string containing the full OPF XML content.
|
318
|
+
"""
|
319
|
+
now_iso = datetime.now(UTC).replace(microsecond=0).isoformat()
|
320
|
+
|
321
|
+
# <package> root
|
322
|
+
package = PKG.package(**OPF_PKG_ATTRIB)
|
323
|
+
|
324
|
+
# <metadata>
|
325
|
+
metadata = PKG.metadata()
|
326
|
+
package.append(metadata)
|
327
|
+
|
328
|
+
# modified timestamp
|
329
|
+
modified = PKG.meta(property="dcterms:modified")
|
330
|
+
modified.text = now_iso
|
331
|
+
metadata.append(modified)
|
332
|
+
|
333
|
+
# mandatory DC elements
|
334
|
+
id_el = DC.identifier(id="id")
|
335
|
+
id_el.text = self.uid
|
336
|
+
title_el = DC.title()
|
337
|
+
title_el.text = self.title
|
338
|
+
lang_el = DC.language()
|
339
|
+
lang_el.text = self.language
|
340
|
+
metadata.extend([id_el, title_el, lang_el])
|
341
|
+
|
342
|
+
# optional DC elements
|
343
|
+
if self.author:
|
344
|
+
creator = DC.creator(id="creator")
|
345
|
+
creator.text = self.author
|
346
|
+
metadata.append(creator)
|
347
|
+
if self.description:
|
348
|
+
desc = DC.description()
|
349
|
+
desc.text = self.description
|
350
|
+
metadata.append(desc)
|
351
|
+
if self.subject:
|
352
|
+
subj = DC.subject()
|
353
|
+
subj.text = ",".join(self.subject)
|
354
|
+
metadata.append(subj)
|
355
|
+
if self.include_cover and self._cover_item:
|
356
|
+
cover_meta = PKG.meta(name="cover", content=self._cover_item.id)
|
357
|
+
metadata.append(cover_meta)
|
358
|
+
|
359
|
+
# <manifest>
|
360
|
+
manifest_el = PKG.manifest()
|
361
|
+
for item in self.manifest:
|
362
|
+
attrs = {
|
363
|
+
"id": item.id,
|
364
|
+
"href": item.href,
|
365
|
+
"media-type": item.media_type,
|
366
|
+
}
|
367
|
+
if item.properties:
|
368
|
+
attrs["properties"] = item.properties
|
369
|
+
manifest_el.append(PKG.item(**attrs))
|
370
|
+
package.append(manifest_el)
|
371
|
+
|
372
|
+
# <spine>
|
373
|
+
spine_attrs = {}
|
374
|
+
if self._toc_item:
|
375
|
+
spine_attrs["toc"] = self._toc_item.id
|
376
|
+
spine_el = PKG.spine(**spine_attrs)
|
377
|
+
for ref in self.spine:
|
378
|
+
attrs = {"idref": ref.idref}
|
379
|
+
if ref.properties:
|
380
|
+
attrs["properties"] = ref.properties
|
381
|
+
spine_el.append(PKG.itemref(**attrs))
|
382
|
+
package.append(spine_el)
|
383
|
+
|
384
|
+
# optional <guide> for cover
|
385
|
+
if self.include_cover and self._cover_doc:
|
386
|
+
guide_el = PKG.guide()
|
387
|
+
guide_el.append(
|
388
|
+
PKG.reference(
|
389
|
+
type="cover",
|
390
|
+
title="Cover",
|
391
|
+
href=self._cover_doc.href,
|
392
|
+
)
|
393
|
+
)
|
394
|
+
package.append(guide_el)
|
395
|
+
|
396
|
+
xml_bytes = etree.tostring(
|
397
|
+
package,
|
398
|
+
xml_declaration=True,
|
399
|
+
encoding="utf-8",
|
400
|
+
pretty_print=PRETTY_PRINT_FLAG,
|
401
|
+
)
|
402
|
+
xml_string: str = xml_bytes.decode("utf-8")
|
403
|
+
return xml_string
|
@@ -0,0 +1,134 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.utils.epub.models
|
4
|
+
----------------------------------
|
5
|
+
|
6
|
+
Defines the core EPUB data models and resource classes used by the builder:
|
7
|
+
- Typed entries for table of contents (ChapterEntry, VolumeEntry)
|
8
|
+
- Manifest and spine record types (ManifestEntry, SpineEntry)
|
9
|
+
- Hierarchical NavPoint for NCX navigation
|
10
|
+
- Base resource class (EpubResource) and specializations:
|
11
|
+
- StyleSheet
|
12
|
+
- ImageResource
|
13
|
+
- Chapter (with XHTML serialization)
|
14
|
+
- Volume container for grouping chapters with optional intro and cover
|
15
|
+
"""
|
16
|
+
|
17
|
+
from __future__ import annotations
|
18
|
+
|
19
|
+
from dataclasses import dataclass, field
|
20
|
+
from pathlib import Path
|
21
|
+
|
22
|
+
from .constants import (
|
23
|
+
CHAP_TMPLATE,
|
24
|
+
CSS_TMPLATE,
|
25
|
+
)
|
26
|
+
|
27
|
+
|
28
|
+
@dataclass(frozen=True)
|
29
|
+
class ChapterEntry:
|
30
|
+
id: str
|
31
|
+
label: str
|
32
|
+
src: str
|
33
|
+
|
34
|
+
|
35
|
+
@dataclass(frozen=True)
|
36
|
+
class VolumeEntry:
|
37
|
+
id: str
|
38
|
+
label: str
|
39
|
+
src: str
|
40
|
+
chapters: list[ChapterEntry]
|
41
|
+
|
42
|
+
|
43
|
+
@dataclass(frozen=True)
|
44
|
+
class ManifestEntry:
|
45
|
+
id: str
|
46
|
+
href: str
|
47
|
+
media_type: str
|
48
|
+
properties: str | None = None
|
49
|
+
|
50
|
+
|
51
|
+
@dataclass(frozen=True)
|
52
|
+
class SpineEntry:
|
53
|
+
idref: str
|
54
|
+
properties: str | None = None
|
55
|
+
|
56
|
+
|
57
|
+
@dataclass
|
58
|
+
class NavPoint:
|
59
|
+
"""
|
60
|
+
A table-of-contents entry, possibly with nested children.
|
61
|
+
"""
|
62
|
+
|
63
|
+
id: str
|
64
|
+
label: str
|
65
|
+
src: str
|
66
|
+
children: list[NavPoint] = field(default_factory=list)
|
67
|
+
|
68
|
+
def add_child(self, point: NavPoint) -> None:
|
69
|
+
"""
|
70
|
+
Append a child nav point under this one.
|
71
|
+
"""
|
72
|
+
self.children.append(point)
|
73
|
+
|
74
|
+
|
75
|
+
@dataclass
|
76
|
+
class EpubResource:
|
77
|
+
"""
|
78
|
+
Base class for any EPUB-packaged resource.
|
79
|
+
"""
|
80
|
+
|
81
|
+
id: str
|
82
|
+
filename: str
|
83
|
+
media_type: str
|
84
|
+
|
85
|
+
|
86
|
+
@dataclass
|
87
|
+
class StyleSheet(EpubResource):
|
88
|
+
content: str
|
89
|
+
media_type: str = field(init=False, default="text/css")
|
90
|
+
|
91
|
+
|
92
|
+
@dataclass
|
93
|
+
class ImageResource(EpubResource):
|
94
|
+
data: bytes
|
95
|
+
|
96
|
+
|
97
|
+
@dataclass
|
98
|
+
class Chapter(EpubResource):
|
99
|
+
title: str
|
100
|
+
content: str
|
101
|
+
css: list[StyleSheet] = field(default_factory=list)
|
102
|
+
media_type: str = field(init=False, default="application/xhtml+xml")
|
103
|
+
|
104
|
+
def __post_init__(self) -> None:
|
105
|
+
if not self.filename:
|
106
|
+
object.__setattr__(self, "filename", f"{self.id}.xhtml")
|
107
|
+
|
108
|
+
def to_xhtml(self, lang: str = "zh-CN") -> str:
|
109
|
+
"""
|
110
|
+
Generate the XHTML for a chapter.
|
111
|
+
"""
|
112
|
+
links = "\n".join(
|
113
|
+
CSS_TMPLATE.format(filename=css.filename, media_type=css.media_type)
|
114
|
+
for css in self.css
|
115
|
+
)
|
116
|
+
return CHAP_TMPLATE.format(
|
117
|
+
lang=lang,
|
118
|
+
title=self.title,
|
119
|
+
xlinks=links,
|
120
|
+
content=self.content,
|
121
|
+
)
|
122
|
+
|
123
|
+
|
124
|
+
@dataclass
|
125
|
+
class Volume:
|
126
|
+
id: str
|
127
|
+
title: str
|
128
|
+
intro: str = ""
|
129
|
+
cover: Path | None = None
|
130
|
+
chapters: list[Chapter] = field(default_factory=list)
|
131
|
+
|
132
|
+
def add_chapter(self, chapter: Chapter) -> None:
|
133
|
+
"""Append a chapter to this volume."""
|
134
|
+
self.chapters.append(chapter)
|