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.
Files changed (165) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +2 -2
  3. novel_downloader/cli/config.py +1 -83
  4. novel_downloader/cli/download.py +4 -5
  5. novel_downloader/cli/export.py +4 -1
  6. novel_downloader/cli/main.py +2 -0
  7. novel_downloader/cli/search.py +123 -0
  8. novel_downloader/config/__init__.py +3 -10
  9. novel_downloader/config/adapter.py +190 -54
  10. novel_downloader/config/loader.py +2 -3
  11. novel_downloader/core/__init__.py +13 -13
  12. novel_downloader/core/downloaders/__init__.py +10 -11
  13. novel_downloader/core/downloaders/base.py +152 -26
  14. novel_downloader/core/downloaders/biquge.py +5 -1
  15. novel_downloader/core/downloaders/common.py +157 -378
  16. novel_downloader/core/downloaders/esjzone.py +5 -1
  17. novel_downloader/core/downloaders/linovelib.py +5 -1
  18. novel_downloader/core/downloaders/qianbi.py +291 -4
  19. novel_downloader/core/downloaders/qidian.py +199 -285
  20. novel_downloader/core/downloaders/registry.py +67 -0
  21. novel_downloader/core/downloaders/sfacg.py +5 -1
  22. novel_downloader/core/downloaders/yamibo.py +5 -1
  23. novel_downloader/core/exporters/__init__.py +10 -11
  24. novel_downloader/core/exporters/base.py +87 -7
  25. novel_downloader/core/exporters/biquge.py +5 -8
  26. novel_downloader/core/exporters/common/__init__.py +2 -2
  27. novel_downloader/core/exporters/common/epub.py +82 -166
  28. novel_downloader/core/exporters/common/main_exporter.py +0 -60
  29. novel_downloader/core/exporters/common/txt.py +82 -83
  30. novel_downloader/core/exporters/epub_util.py +157 -1330
  31. novel_downloader/core/exporters/esjzone.py +5 -8
  32. novel_downloader/core/exporters/linovelib/__init__.py +2 -2
  33. novel_downloader/core/exporters/linovelib/epub.py +157 -212
  34. novel_downloader/core/exporters/linovelib/main_exporter.py +2 -59
  35. novel_downloader/core/exporters/linovelib/txt.py +67 -63
  36. novel_downloader/core/exporters/qianbi.py +5 -8
  37. novel_downloader/core/exporters/qidian.py +14 -4
  38. novel_downloader/core/exporters/registry.py +53 -0
  39. novel_downloader/core/exporters/sfacg.py +5 -8
  40. novel_downloader/core/exporters/txt_util.py +67 -0
  41. novel_downloader/core/exporters/yamibo.py +5 -8
  42. novel_downloader/core/fetchers/__init__.py +19 -24
  43. novel_downloader/core/fetchers/base/__init__.py +3 -3
  44. novel_downloader/core/fetchers/base/browser.py +23 -4
  45. novel_downloader/core/fetchers/base/session.py +30 -5
  46. novel_downloader/core/fetchers/biquge/__init__.py +3 -3
  47. novel_downloader/core/fetchers/biquge/browser.py +5 -0
  48. novel_downloader/core/fetchers/biquge/session.py +6 -1
  49. novel_downloader/core/fetchers/esjzone/__init__.py +3 -3
  50. novel_downloader/core/fetchers/esjzone/browser.py +5 -0
  51. novel_downloader/core/fetchers/esjzone/session.py +6 -1
  52. novel_downloader/core/fetchers/linovelib/__init__.py +3 -3
  53. novel_downloader/core/fetchers/linovelib/browser.py +6 -1
  54. novel_downloader/core/fetchers/linovelib/session.py +6 -1
  55. novel_downloader/core/fetchers/qianbi/__init__.py +3 -3
  56. novel_downloader/core/fetchers/qianbi/browser.py +5 -0
  57. novel_downloader/core/fetchers/qianbi/session.py +5 -0
  58. novel_downloader/core/fetchers/qidian/__init__.py +3 -3
  59. novel_downloader/core/fetchers/qidian/browser.py +12 -4
  60. novel_downloader/core/fetchers/qidian/session.py +11 -3
  61. novel_downloader/core/fetchers/registry.py +71 -0
  62. novel_downloader/core/fetchers/sfacg/__init__.py +3 -3
  63. novel_downloader/core/fetchers/sfacg/browser.py +5 -0
  64. novel_downloader/core/fetchers/sfacg/session.py +5 -0
  65. novel_downloader/core/fetchers/yamibo/__init__.py +3 -3
  66. novel_downloader/core/fetchers/yamibo/browser.py +5 -0
  67. novel_downloader/core/fetchers/yamibo/session.py +6 -1
  68. novel_downloader/core/interfaces/__init__.py +7 -5
  69. novel_downloader/core/interfaces/searcher.py +18 -0
  70. novel_downloader/core/parsers/__init__.py +10 -11
  71. novel_downloader/core/parsers/{biquge/main_parser.py → biquge.py} +7 -2
  72. novel_downloader/core/parsers/{esjzone/main_parser.py → esjzone.py} +7 -2
  73. novel_downloader/core/parsers/{linovelib/main_parser.py → linovelib.py} +7 -2
  74. novel_downloader/core/parsers/{qianbi/main_parser.py → qianbi.py} +7 -2
  75. novel_downloader/core/parsers/qidian/__init__.py +2 -2
  76. novel_downloader/core/parsers/qidian/chapter_encrypted.py +23 -21
  77. novel_downloader/core/parsers/qidian/chapter_normal.py +1 -1
  78. novel_downloader/core/parsers/qidian/main_parser.py +10 -21
  79. novel_downloader/core/parsers/qidian/utils/__init__.py +11 -11
  80. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +5 -6
  81. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +2 -2
  82. novel_downloader/core/parsers/registry.py +68 -0
  83. novel_downloader/core/parsers/{sfacg/main_parser.py → sfacg.py} +7 -2
  84. novel_downloader/core/parsers/{yamibo/main_parser.py → yamibo.py} +7 -2
  85. novel_downloader/core/searchers/__init__.py +20 -0
  86. novel_downloader/core/searchers/base.py +92 -0
  87. novel_downloader/core/searchers/biquge.py +83 -0
  88. novel_downloader/core/searchers/esjzone.py +84 -0
  89. novel_downloader/core/searchers/qianbi.py +131 -0
  90. novel_downloader/core/searchers/qidian.py +87 -0
  91. novel_downloader/core/searchers/registry.py +63 -0
  92. novel_downloader/locales/en.json +12 -4
  93. novel_downloader/locales/zh.json +12 -4
  94. novel_downloader/models/__init__.py +4 -30
  95. novel_downloader/models/config.py +12 -6
  96. novel_downloader/models/search.py +16 -0
  97. novel_downloader/models/types.py +0 -2
  98. novel_downloader/resources/config/settings.toml +31 -4
  99. novel_downloader/resources/css_styles/intro.css +83 -0
  100. novel_downloader/resources/css_styles/main.css +30 -89
  101. novel_downloader/utils/__init__.py +52 -0
  102. novel_downloader/utils/chapter_storage.py +244 -224
  103. novel_downloader/utils/constants.py +1 -21
  104. novel_downloader/utils/epub/__init__.py +34 -0
  105. novel_downloader/utils/epub/builder.py +377 -0
  106. novel_downloader/utils/epub/constants.py +77 -0
  107. novel_downloader/utils/epub/documents.py +403 -0
  108. novel_downloader/utils/epub/models.py +134 -0
  109. novel_downloader/utils/epub/utils.py +212 -0
  110. novel_downloader/utils/file_utils/__init__.py +10 -14
  111. novel_downloader/utils/file_utils/io.py +20 -51
  112. novel_downloader/utils/file_utils/normalize.py +2 -2
  113. novel_downloader/utils/file_utils/sanitize.py +2 -3
  114. novel_downloader/utils/fontocr/__init__.py +5 -5
  115. novel_downloader/utils/{hash_store.py → fontocr/hash_store.py} +4 -3
  116. novel_downloader/utils/{hash_utils.py → fontocr/hash_utils.py} +2 -2
  117. novel_downloader/utils/fontocr/ocr_v1.py +13 -1
  118. novel_downloader/utils/fontocr/ocr_v2.py +13 -1
  119. novel_downloader/utils/fontocr/ocr_v3.py +744 -0
  120. novel_downloader/utils/i18n.py +2 -0
  121. novel_downloader/utils/logger.py +2 -0
  122. novel_downloader/utils/network.py +110 -251
  123. novel_downloader/utils/state.py +1 -0
  124. novel_downloader/utils/text_utils/__init__.py +18 -17
  125. novel_downloader/utils/text_utils/diff_display.py +4 -5
  126. novel_downloader/utils/text_utils/numeric_conversion.py +253 -0
  127. novel_downloader/utils/text_utils/text_cleaner.py +179 -0
  128. novel_downloader/utils/text_utils/truncate_utils.py +62 -0
  129. novel_downloader/utils/time_utils/__init__.py +3 -3
  130. novel_downloader/utils/time_utils/datetime_utils.py +4 -5
  131. novel_downloader/utils/time_utils/sleep_utils.py +2 -3
  132. {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/METADATA +2 -2
  133. novel_downloader-1.5.0.dist-info/RECORD +164 -0
  134. novel_downloader/config/site_rules.py +0 -94
  135. novel_downloader/core/factory/__init__.py +0 -20
  136. novel_downloader/core/factory/downloader.py +0 -73
  137. novel_downloader/core/factory/exporter.py +0 -58
  138. novel_downloader/core/factory/fetcher.py +0 -96
  139. novel_downloader/core/factory/parser.py +0 -86
  140. novel_downloader/core/fetchers/common/__init__.py +0 -14
  141. novel_downloader/core/fetchers/common/browser.py +0 -79
  142. novel_downloader/core/fetchers/common/session.py +0 -79
  143. novel_downloader/core/parsers/biquge/__init__.py +0 -10
  144. novel_downloader/core/parsers/common/__init__.py +0 -13
  145. novel_downloader/core/parsers/common/helper.py +0 -323
  146. novel_downloader/core/parsers/common/main_parser.py +0 -106
  147. novel_downloader/core/parsers/esjzone/__init__.py +0 -10
  148. novel_downloader/core/parsers/linovelib/__init__.py +0 -10
  149. novel_downloader/core/parsers/qianbi/__init__.py +0 -10
  150. novel_downloader/core/parsers/sfacg/__init__.py +0 -10
  151. novel_downloader/core/parsers/yamibo/__init__.py +0 -10
  152. novel_downloader/models/browser.py +0 -21
  153. novel_downloader/models/site_rules.py +0 -99
  154. novel_downloader/models/tasks.py +0 -33
  155. novel_downloader/resources/css_styles/volume-intro.css +0 -56
  156. novel_downloader/resources/json/replace_word_map.json +0 -4
  157. novel_downloader/resources/text/blacklist.txt +0 -22
  158. novel_downloader/utils/text_utils/chapter_formatting.py +0 -46
  159. novel_downloader/utils/text_utils/font_mapping.py +0 -28
  160. novel_downloader/utils/text_utils/text_cleaning.py +0 -107
  161. novel_downloader-1.4.4.dist-info/RECORD +0 -165
  162. {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/WHEEL +0 -0
  163. {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/entry_points.txt +0 -0
  164. {novel_downloader-1.4.4.dist-info → novel_downloader-1.5.0.dist-info}/licenses/LICENSE +0 -0
  165. {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)