novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__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 (248) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +1 -3
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +26 -21
  5. novel_downloader/cli/download.py +79 -66
  6. novel_downloader/cli/export.py +17 -21
  7. novel_downloader/cli/main.py +1 -1
  8. novel_downloader/cli/search.py +62 -65
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +8 -5
  11. novel_downloader/config/adapter.py +206 -209
  12. novel_downloader/config/{loader.py → file_io.py} +53 -26
  13. novel_downloader/core/__init__.py +5 -5
  14. novel_downloader/core/archived/deqixs/fetcher.py +115 -0
  15. novel_downloader/core/archived/deqixs/parser.py +132 -0
  16. novel_downloader/core/archived/deqixs/searcher.py +89 -0
  17. novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
  18. novel_downloader/core/archived/wanbengo/searcher.py +98 -0
  19. novel_downloader/core/archived/xshbook/searcher.py +93 -0
  20. novel_downloader/core/downloaders/__init__.py +3 -24
  21. novel_downloader/core/downloaders/base.py +49 -23
  22. novel_downloader/core/downloaders/common.py +191 -137
  23. novel_downloader/core/downloaders/qianbi.py +187 -146
  24. novel_downloader/core/downloaders/qidian.py +187 -141
  25. novel_downloader/core/downloaders/registry.py +4 -2
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +3 -20
  28. novel_downloader/core/exporters/base.py +33 -37
  29. novel_downloader/core/exporters/common/__init__.py +1 -2
  30. novel_downloader/core/exporters/common/epub.py +15 -10
  31. novel_downloader/core/exporters/common/main_exporter.py +19 -12
  32. novel_downloader/core/exporters/common/txt.py +17 -12
  33. novel_downloader/core/exporters/epub_util.py +59 -29
  34. novel_downloader/core/exporters/linovelib/__init__.py +1 -0
  35. novel_downloader/core/exporters/linovelib/epub.py +23 -25
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
  37. novel_downloader/core/exporters/linovelib/txt.py +20 -14
  38. novel_downloader/core/exporters/qidian.py +2 -8
  39. novel_downloader/core/exporters/registry.py +4 -2
  40. novel_downloader/core/exporters/txt_util.py +7 -7
  41. novel_downloader/core/fetchers/__init__.py +54 -48
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
  45. novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
  46. novel_downloader/core/fetchers/dxmwx.py +110 -0
  47. novel_downloader/core/fetchers/eightnovel.py +139 -0
  48. novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
  49. novel_downloader/core/fetchers/guidaye.py +85 -0
  50. novel_downloader/core/fetchers/hetushu.py +92 -0
  51. novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/lewenn.py +83 -0
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
  56. novel_downloader/core/fetchers/piaotia.py +105 -0
  57. novel_downloader/core/fetchers/qbtr.py +101 -0
  58. novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
  60. novel_downloader/core/fetchers/quanben5.py +92 -0
  61. novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
  62. novel_downloader/core/fetchers/registry.py +5 -16
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/shuhaige.py +84 -0
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/wanbengo.py +83 -0
  69. novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
  70. novel_downloader/core/fetchers/xiguashuwu.py +177 -0
  71. novel_downloader/core/fetchers/xs63b.py +171 -0
  72. novel_downloader/core/fetchers/xshbook.py +85 -0
  73. novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +1 -9
  76. novel_downloader/core/interfaces/downloader.py +6 -2
  77. novel_downloader/core/interfaces/exporter.py +7 -7
  78. novel_downloader/core/interfaces/fetcher.py +6 -19
  79. novel_downloader/core/interfaces/parser.py +7 -8
  80. novel_downloader/core/interfaces/searcher.py +9 -1
  81. novel_downloader/core/parsers/__init__.py +49 -12
  82. novel_downloader/core/parsers/aaatxt.py +132 -0
  83. novel_downloader/core/parsers/b520.py +116 -0
  84. novel_downloader/core/parsers/base.py +64 -12
  85. novel_downloader/core/parsers/biquyuedu.py +133 -0
  86. novel_downloader/core/parsers/dxmwx.py +162 -0
  87. novel_downloader/core/parsers/eightnovel.py +224 -0
  88. novel_downloader/core/parsers/esjzone.py +64 -69
  89. novel_downloader/core/parsers/guidaye.py +128 -0
  90. novel_downloader/core/parsers/hetushu.py +139 -0
  91. novel_downloader/core/parsers/i25zw.py +137 -0
  92. novel_downloader/core/parsers/ixdzs8.py +186 -0
  93. novel_downloader/core/parsers/jpxs123.py +137 -0
  94. novel_downloader/core/parsers/lewenn.py +142 -0
  95. novel_downloader/core/parsers/linovelib.py +48 -64
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/qianbi.py +48 -50
  99. novel_downloader/core/parsers/qidian/main_parser.py +756 -48
  100. novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
  101. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
  102. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
  103. novel_downloader/core/parsers/quanben5.py +103 -0
  104. novel_downloader/core/parsers/registry.py +5 -16
  105. novel_downloader/core/parsers/sfacg.py +38 -45
  106. novel_downloader/core/parsers/shencou.py +215 -0
  107. novel_downloader/core/parsers/shuhaige.py +111 -0
  108. novel_downloader/core/parsers/tongrenquan.py +116 -0
  109. novel_downloader/core/parsers/ttkan.py +132 -0
  110. novel_downloader/core/parsers/wanbengo.py +191 -0
  111. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  112. novel_downloader/core/parsers/xiguashuwu.py +429 -0
  113. novel_downloader/core/parsers/xs63b.py +161 -0
  114. novel_downloader/core/parsers/xshbook.py +134 -0
  115. novel_downloader/core/parsers/yamibo.py +87 -131
  116. novel_downloader/core/parsers/yibige.py +166 -0
  117. novel_downloader/core/searchers/__init__.py +34 -3
  118. novel_downloader/core/searchers/aaatxt.py +107 -0
  119. novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
  120. novel_downloader/core/searchers/base.py +112 -36
  121. novel_downloader/core/searchers/dxmwx.py +105 -0
  122. novel_downloader/core/searchers/eightnovel.py +84 -0
  123. novel_downloader/core/searchers/esjzone.py +43 -25
  124. novel_downloader/core/searchers/hetushu.py +92 -0
  125. novel_downloader/core/searchers/i25zw.py +93 -0
  126. novel_downloader/core/searchers/ixdzs8.py +107 -0
  127. novel_downloader/core/searchers/jpxs123.py +107 -0
  128. novel_downloader/core/searchers/piaotia.py +100 -0
  129. novel_downloader/core/searchers/qbtr.py +106 -0
  130. novel_downloader/core/searchers/qianbi.py +74 -40
  131. novel_downloader/core/searchers/quanben5.py +144 -0
  132. novel_downloader/core/searchers/registry.py +24 -8
  133. novel_downloader/core/searchers/shuhaige.py +124 -0
  134. novel_downloader/core/searchers/tongrenquan.py +110 -0
  135. novel_downloader/core/searchers/ttkan.py +92 -0
  136. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  137. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  138. novel_downloader/core/searchers/xs63b.py +104 -0
  139. novel_downloader/locales/en.json +34 -85
  140. novel_downloader/locales/zh.json +35 -86
  141. novel_downloader/models/__init__.py +21 -22
  142. novel_downloader/models/book.py +44 -0
  143. novel_downloader/models/config.py +4 -37
  144. novel_downloader/models/login.py +1 -1
  145. novel_downloader/models/search.py +5 -0
  146. novel_downloader/resources/config/settings.toml +8 -70
  147. novel_downloader/resources/json/xiguashuwu.json +718 -0
  148. novel_downloader/utils/__init__.py +13 -24
  149. novel_downloader/utils/chapter_storage.py +5 -5
  150. novel_downloader/utils/constants.py +4 -31
  151. novel_downloader/utils/cookies.py +38 -35
  152. novel_downloader/utils/crypto_utils/__init__.py +7 -0
  153. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  154. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  155. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  156. novel_downloader/utils/crypto_utils/rc4.py +54 -0
  157. novel_downloader/utils/epub/__init__.py +3 -4
  158. novel_downloader/utils/epub/builder.py +6 -6
  159. novel_downloader/utils/epub/constants.py +62 -21
  160. novel_downloader/utils/epub/documents.py +95 -201
  161. novel_downloader/utils/epub/models.py +8 -22
  162. novel_downloader/utils/epub/utils.py +73 -106
  163. novel_downloader/utils/file_utils/__init__.py +2 -23
  164. novel_downloader/utils/file_utils/io.py +53 -188
  165. novel_downloader/utils/file_utils/normalize.py +1 -7
  166. novel_downloader/utils/file_utils/sanitize.py +4 -15
  167. novel_downloader/utils/fontocr/__init__.py +5 -14
  168. novel_downloader/utils/fontocr/core.py +216 -0
  169. novel_downloader/utils/fontocr/loader.py +50 -0
  170. novel_downloader/utils/logger.py +81 -65
  171. novel_downloader/utils/network.py +17 -41
  172. novel_downloader/utils/state.py +4 -90
  173. novel_downloader/utils/text_utils/__init__.py +1 -7
  174. novel_downloader/utils/text_utils/diff_display.py +5 -7
  175. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  176. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  177. novel_downloader/utils/time_utils/__init__.py +5 -11
  178. novel_downloader/utils/time_utils/datetime_utils.py +20 -29
  179. novel_downloader/utils/time_utils/sleep_utils.py +55 -49
  180. novel_downloader/web/__init__.py +13 -0
  181. novel_downloader/web/components/__init__.py +11 -0
  182. novel_downloader/web/components/navigation.py +35 -0
  183. novel_downloader/web/main.py +66 -0
  184. novel_downloader/web/pages/__init__.py +17 -0
  185. novel_downloader/web/pages/download.py +78 -0
  186. novel_downloader/web/pages/progress.py +147 -0
  187. novel_downloader/web/pages/search.py +329 -0
  188. novel_downloader/web/services/__init__.py +17 -0
  189. novel_downloader/web/services/client_dialog.py +164 -0
  190. novel_downloader/web/services/cred_broker.py +113 -0
  191. novel_downloader/web/services/cred_models.py +35 -0
  192. novel_downloader/web/services/task_manager.py +264 -0
  193. novel_downloader-2.0.1.dist-info/METADATA +172 -0
  194. novel_downloader-2.0.1.dist-info/RECORD +206 -0
  195. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
  196. novel_downloader/core/downloaders/biquge.py +0 -29
  197. novel_downloader/core/downloaders/esjzone.py +0 -29
  198. novel_downloader/core/downloaders/linovelib.py +0 -29
  199. novel_downloader/core/downloaders/sfacg.py +0 -29
  200. novel_downloader/core/downloaders/yamibo.py +0 -29
  201. novel_downloader/core/exporters/biquge.py +0 -22
  202. novel_downloader/core/exporters/esjzone.py +0 -22
  203. novel_downloader/core/exporters/qianbi.py +0 -22
  204. novel_downloader/core/exporters/sfacg.py +0 -22
  205. novel_downloader/core/exporters/yamibo.py +0 -22
  206. novel_downloader/core/fetchers/base/__init__.py +0 -14
  207. novel_downloader/core/fetchers/base/browser.py +0 -422
  208. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  209. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  210. novel_downloader/core/fetchers/esjzone/browser.py +0 -209
  211. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  212. novel_downloader/core/fetchers/linovelib/browser.py +0 -198
  213. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  214. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  215. novel_downloader/core/fetchers/qidian/browser.py +0 -326
  216. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  217. novel_downloader/core/fetchers/sfacg/browser.py +0 -194
  218. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  219. novel_downloader/core/fetchers/yamibo/browser.py +0 -234
  220. novel_downloader/core/parsers/biquge.py +0 -139
  221. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
  222. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
  223. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
  224. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  225. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
  226. novel_downloader/models/chapter.py +0 -25
  227. novel_downloader/models/types.py +0 -13
  228. novel_downloader/tui/__init__.py +0 -7
  229. novel_downloader/tui/app.py +0 -32
  230. novel_downloader/tui/main.py +0 -17
  231. novel_downloader/tui/screens/__init__.py +0 -14
  232. novel_downloader/tui/screens/home.py +0 -198
  233. novel_downloader/tui/screens/login.py +0 -74
  234. novel_downloader/tui/styles/home_layout.tcss +0 -79
  235. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  236. novel_downloader/utils/cache.py +0 -24
  237. novel_downloader/utils/crypto_utils.py +0 -71
  238. novel_downloader/utils/fontocr/hash_store.py +0 -280
  239. novel_downloader/utils/fontocr/hash_utils.py +0 -103
  240. novel_downloader/utils/fontocr/model_loader.py +0 -69
  241. novel_downloader/utils/fontocr/ocr_v1.py +0 -315
  242. novel_downloader/utils/fontocr/ocr_v2.py +0 -764
  243. novel_downloader/utils/fontocr/ocr_v3.py +0 -744
  244. novel_downloader-1.5.0.dist-info/METADATA +0 -196
  245. novel_downloader-1.5.0.dist-info/RECORD +0 -164
  246. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
  247. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
  248. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/top_level.txt +0 -0
@@ -4,20 +4,18 @@ novel_downloader.utils.epub.utils
4
4
  ---------------------------------
5
5
 
6
6
  Pure utility functions for EPUB assembly, including:
7
- - Computing file hashes
8
- - Generating META-INF/container.xml
9
- - Constructing HTML snippets for the book intro and volume intro
7
+ * Computing file hashes
8
+ * Generating META-INF/container.xml
9
+ * Constructing HTML snippets for the book intro and volume intro
10
10
  """
11
11
 
12
12
  import hashlib
13
+ from html import escape
13
14
  from pathlib import Path
14
15
 
15
- from lxml import etree, html
16
-
17
16
  from .constants import (
18
17
  CONTAINER_TEMPLATE,
19
18
  IMAGE_FOLDER,
20
- PRETTY_PRINT_FLAG,
21
19
  ROOT_PATH,
22
20
  )
23
21
 
@@ -61,48 +59,49 @@ def build_book_intro(
61
59
  Build the HTML snippet for the overall book introduction.
62
60
 
63
61
  This includes:
64
- - A main heading ("Book Introduction")
65
- - A list of metadata items (title, author, categories, word count, status)
66
- - A "Summary" subheading and one or more paragraphs of summary text
62
+ * A main heading ("Book Introduction")
63
+ * A list of metadata items (title, author, categories, word count, status)
64
+ * A "Summary" subheading and one or more paragraphs of summary text
67
65
 
68
66
  :return: A HTML string for inclusion in `intro.xhtml`
69
67
  """
70
- root = html.Element("div")
71
-
72
- # Main heading
73
- h1 = etree.SubElement(root, "h1")
74
- h1.text = "书籍简介"
75
-
76
- # Metadata list
77
- info_div = etree.SubElement(root, "div", {"class": "intro-info"})
78
- ul = etree.SubElement(info_div, "ul")
79
- _add_li(ul, "书名", f"《{book_name}》" if book_name else "")
80
- _add_li(ul, "作者", author)
81
- _add_li(ul, "分类", ", ".join(subject) if subject else "")
82
- _add_li(ul, "字数", word_count)
83
- _add_li(ul, "状态", serial_status)
84
-
85
- # Summary section
86
- if summary:
87
- # force page break before summary
88
- etree.SubElement(root, "p", {"class": "new-page-after"})
89
- h2 = etree.SubElement(root, "h2")
90
- h2.text = "简介"
68
+ lines = []
69
+
70
+ lines.append("<div>")
71
+ lines.append("<h1>书籍简介</h1>")
72
+ lines.append('<div class="intro-info">')
73
+ lines.append("<ul>")
74
+
75
+ name_val = f"{book_name}》" if book_name else ""
76
+ subj_val = ", ".join(subject) if subject else ""
77
+
78
+ li_lines = [
79
+ _li_line("书名", name_val),
80
+ _li_line("作者", author),
81
+ _li_line("分类", subj_val),
82
+ _li_line("字数", word_count),
83
+ _li_line("状态", serial_status),
84
+ ]
85
+ for li in li_lines:
86
+ if li:
87
+ lines.append(li)
88
+
89
+ lines.append("</ul>")
90
+ lines.append("</div>")
91
91
 
92
- summary_div = etree.SubElement(root, "div", {"class": "intro-summary"})
92
+ if summary:
93
+ lines.append('<p class="new-page-after"></p>')
94
+ lines.append("<h2>简介</h2>")
95
+ lines.append('<div class="intro-summary">')
93
96
  for line in summary.splitlines():
94
- line = line.strip()
95
- if not line:
97
+ s = line.strip()
98
+ if not s:
96
99
  continue
97
- p = etree.SubElement(summary_div, "p")
98
- p.text = line
100
+ lines.append(f"<p>{escape(s, quote=True)}</p>")
101
+ lines.append("</div>")
99
102
 
100
- html_string: str = html.tostring(
101
- root,
102
- pretty_print=PRETTY_PRINT_FLAG,
103
- encoding="unicode",
104
- )
105
- return html_string
103
+ lines.append("</div>")
104
+ return "\n".join(lines)
106
105
 
107
106
 
108
107
  def build_volume_intro(
@@ -113,86 +112,54 @@ def build_volume_intro(
113
112
  Build the HTML snippet for a single-volume introduction.
114
113
 
115
114
  This includes:
116
- - A decorative border image (top and bottom)
117
- - A primary heading (volume main title)
118
- - An optional secondary line (subtitle)
119
- - One or more paragraphs of intro text
115
+ * A decorative border image (top and bottom)
116
+ * A primary heading (volume main title)
117
+ * An optional secondary line (subtitle)
118
+ * One or more paragraphs of intro text
120
119
 
121
120
  :param volume_title: e.g. "Volume 1 - The Beginning"
122
121
  :param volume_intro_text: multiline intro text for this volume
123
122
  :return: A HTML string for inclusion in `vol_<n>.xhtml`
124
123
  """
125
- root = html.Element("div")
126
-
127
- # Break the title into two lines if possible
128
124
  line1, line2 = _split_volume_title(volume_title)
129
125
 
130
- header = etree.SubElement(root, "div", {"class": "vol-header"})
131
-
132
- # Top decorative border
133
- header.append(_make_vol_border_img(flip=False))
134
-
135
- # Main title
136
- h1 = etree.SubElement(header, "h1", {"class": "vol-title-main"})
137
- h1.text = line1
138
-
139
- # Bottom decorative border (flipped)
140
- header.append(_make_vol_border_img(flip=True))
141
-
142
- # Subtitle (if any)
126
+ lines = []
127
+ lines.append("<div>")
128
+ lines.append('<div class="vol-header">')
129
+ lines.append(_vol_border_div_str(flip=False))
130
+ lines.append(f'<h1 class="vol-title-main">{escape(line1, quote=True)}</h1>')
131
+ lines.append(_vol_border_div_str(flip=True))
143
132
  if line2:
144
- h2 = etree.SubElement(header, "h2", {"class": "vol-title-sub"})
145
- h2.text = line2
133
+ lines.append(f'<h2 class="vol-title-sub">{escape(line2, quote=True)}</h2>')
134
+ lines.append("</div>")
146
135
 
147
- # Intro text paragraphs
148
136
  if volume_intro_text:
149
- etree.SubElement(root, "p", {"class": "new-page-after"})
150
-
151
- vol_div = etree.SubElement(root, "div", {"class": "vol-intro-text"})
137
+ lines.append('<p class="new-page-after"></p>')
138
+ lines.append('<div class="vol-intro-text">')
152
139
  for line in volume_intro_text.splitlines():
153
- line = line.strip()
154
- if not line:
140
+ s = line.strip()
141
+ if not s:
155
142
  continue
156
- p = etree.SubElement(vol_div, "p")
157
- p.text = line
143
+ lines.append(f"<p>{escape(s, quote=True)}</p>")
144
+ lines.append("</div>")
158
145
 
159
- html_string: str = html.tostring(
160
- root,
161
- pretty_print=PRETTY_PRINT_FLAG,
162
- encoding="unicode",
163
- )
164
- return html_string
146
+ lines.append("</div>")
147
+ return "\n".join(lines)
165
148
 
166
149
 
167
- def _add_li(ul: etree._Element, label: str, value: str) -> None:
168
- """
169
- Append a `<li>` with 'label: value' if value is nonempty.
170
- """
171
- if value:
172
- li = etree.SubElement(ul, "li")
173
- li.text = f"{label}: {value}"
150
+ def _li_line(label: str, value: str) -> str:
151
+ if not value:
152
+ return ""
153
+ return f"<li>{escape(label, quote=True)}: {escape(value, quote=True)}</li>"
174
154
 
175
155
 
176
- def _make_vol_border_img(flip: bool = False) -> html.HtmlElement:
177
- """
178
- Return a `<div>` containing the `volume_border.png` image,
179
- styled by the given class name.
180
- """
181
- classes = ["vol-border"]
182
- if flip:
183
- classes.append("flip")
184
- cls = " ".join(classes)
185
-
186
- div = html.Element("div", {"class": cls})
187
- etree.SubElement(
188
- div,
189
- "img",
190
- {
191
- "src": f"../{IMAGE_FOLDER}/volume_border.png",
192
- "alt": "",
193
- },
156
+ def _vol_border_div_str(flip: bool = False) -> str:
157
+ classes = "vol-border" + (" flip" if flip else "")
158
+ return (
159
+ f'<div class="{classes}">'
160
+ f'<img src="../{IMAGE_FOLDER}/volume_border.png" alt="">'
161
+ f"</div>"
194
162
  )
195
- return div
196
163
 
197
164
 
198
165
  def _split_volume_title(volume_title: str) -> tuple[str, str]:
@@ -202,10 +169,10 @@ def _split_volume_title(volume_title: str) -> tuple[str, str]:
202
169
  :param volume_title: Original volume title string.
203
170
  :return: Tuple of (line1, line2)
204
171
  """
205
- if " " in volume_title:
206
- parts = volume_title.split(" ", 1)
207
- elif "-" in volume_title:
172
+ if "-" in volume_title:
208
173
  parts = volume_title.split("-", 1)
174
+ elif " " in volume_title:
175
+ parts = volume_title.split(" ", 1)
209
176
  else:
210
177
  return volume_title, ""
211
178
 
@@ -4,35 +4,14 @@ novel_downloader.utils.file_utils
4
4
  ---------------------------------
5
5
 
6
6
  High-level file I/O utility re-exports for convenience.
7
-
8
- This module aggregates commonly used low-level file utilities such as:
9
- - Path sanitization (for safe filenames)
10
- - Text normalization (e.g. Windows/Linux line endings)
11
- - JSON, plain text, and binary file reading/writing
12
-
13
- Included utilities:
14
- - sanitize_filename: remove invalid characters from filenames
15
- - normalize_txt_line_endings: standardize line endings in text files
16
- - save_as_json / save_as_txt: write dict or text to file
17
- - read_text_file / read_json_file / read_binary_file: load content from file
18
7
  """
19
8
 
20
9
  __all__ = [
21
10
  "sanitize_filename",
22
- "save_as_json",
23
- "save_as_txt",
24
- "read_text_file",
25
- "read_json_file",
26
- "read_binary_file",
11
+ "write_file",
27
12
  "normalize_txt_line_endings",
28
13
  ]
29
14
 
30
- from .io import (
31
- read_binary_file,
32
- read_json_file,
33
- read_text_file,
34
- save_as_json,
35
- save_as_txt,
36
- )
15
+ from .io import write_file
37
16
  from .normalize import normalize_txt_line_endings
38
17
  from .sanitize import sanitize_filename
@@ -3,214 +3,79 @@
3
3
  novel_downloader.utils.file_utils.io
4
4
  ------------------------------------
5
5
 
6
- File I/O utilities for reading and writing text, JSON, and binary data.
7
-
8
- Includes:
9
- - Safe, atomic file saving with optional overwrite and auto-renaming
10
- - JSON pretty-printing with size-aware formatting
11
- - Simple helpers for reading files with fallback and logging
6
+ File I/O utilities for reading and writing data.
12
7
  """
13
8
 
14
- __all__ = [
15
- "save_as_txt",
16
- "save_as_json",
17
- "read_text_file",
18
- "read_json_file",
19
- "read_binary_file",
20
- ]
9
+ __all__ = ["write_file"]
21
10
 
22
- import json
23
- import logging
24
11
  import tempfile
25
12
  from pathlib import Path
26
- from typing import Any, Literal
13
+ from typing import Literal
27
14
 
28
15
  from .sanitize import sanitize_filename
29
16
 
30
- logger = logging.getLogger(__name__)
31
-
32
- _JSON_INDENT_THRESHOLD = 50 * 1024 # bytes
33
-
34
-
35
- def _get_non_conflicting_path(path: Path) -> Path:
36
- """
37
- If the path exists, generate a new one by appending _1, _2, etc.
38
- """
39
- counter = 1
40
- new_path = path
41
- while new_path.exists():
42
- stem = path.stem
43
- suffix = path.suffix
44
- new_path = path.with_name(f"{stem}_{counter}{suffix}")
45
- counter += 1
46
- return new_path
47
-
48
-
49
- def _write_file(
50
- content: str | bytes | dict[Any, Any] | list[Any] | Any,
51
- filepath: str | Path,
52
- write_mode: str = "w",
53
- *,
54
- on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
55
- dump_json: bool = False,
56
- encoding: str = "utf-8",
57
- ) -> Path | None:
58
- """
59
- Write content to a file safely with optional atomic behavior
60
- and JSON serialization.
61
-
62
- :param content: The content to write; can be text, bytes, or a
63
- JSON-serializable object.
64
- :param filepath: Destination path (str or Path).
65
- :param mode: File mode ('w', 'wb'). Auto-determined if None.
66
- :param on_exist: Behavior if file exists: 'overwrite', 'skip',
67
- or 'rename'.
68
- :param dump_json: If True, serialize content as JSON.
69
- :param encoding: Text encoding for writing.
70
- :return: Path if writing succeeds, None otherwise.
71
- """
72
- path = Path(filepath)
73
- path = path.with_name(sanitize_filename(path.name))
74
- path.parent.mkdir(parents=True, exist_ok=True)
75
-
76
- if path.exists():
77
- if on_exist == "skip":
78
- logger.debug("[file] '%s' exists, skipping", path)
79
- return path
80
- if on_exist == "rename":
81
- path = _get_non_conflicting_path(path)
82
- logger.debug("[file] Renaming target to avoid conflict: %s", path)
83
- else:
84
- logger.debug("[file] '%s' exists, will overwrite", path)
85
-
86
- # Prepare content and write mode
87
- content_to_write: str | bytes
88
- if dump_json:
89
- # Serialize original object to JSON string
90
- json_str = json.dumps(content, ensure_ascii=False, indent=2)
91
- if len(json_str.encode(encoding)) > _JSON_INDENT_THRESHOLD:
92
- json_str = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
93
- content_to_write = json_str
94
- write_mode = "w"
95
- else:
96
- if isinstance(content, (str | bytes)):
97
- content_to_write = content
98
- else:
99
- raise TypeError("Non-JSON content must be str or bytes.")
100
- write_mode = "wb" if isinstance(content, bytes) else "w"
101
-
102
- try:
103
- with tempfile.NamedTemporaryFile(
104
- mode=write_mode,
105
- encoding=None if "b" in write_mode else encoding,
106
- newline=None if "b" in write_mode else "\n",
107
- delete=False,
108
- dir=path.parent,
109
- ) as tmp:
110
- tmp.write(content_to_write)
111
- tmp_path = Path(tmp.name)
112
- tmp_path.replace(path)
113
- logger.debug("[file] '%s' written successfully", path)
114
- return path
115
- except Exception as exc:
116
- logger.warning("[file] Error writing %r: %s", path, exc)
117
- return None
118
-
119
17
 
120
- def save_as_txt(
121
- content: str,
122
- filepath: str | Path,
123
- *,
124
- encoding: str = "utf-8",
125
- on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
126
- ) -> Path | None:
18
+ def _unique_path(path: Path, max_tries: int = 100) -> Path:
127
19
  """
128
- Save plain text content to the given file path.
20
+ Return a unique file path by appending _1, _2, ... if needed.
129
21
 
130
- :param content: Text content to write.
131
- :param filepath: Destination file path.
132
- :param encoding: Text encoding to use (default: 'utf-8').
133
- :param on_exist: How to handle existing files: 'overwrite', 'skip', or 'rename'.
134
- :return: Path if writing succeeds, None otherwise.
22
+ Falls back to a UUID suffix if all attempts fail.
135
23
  """
136
- return _write_file(
137
- content=content,
138
- filepath=filepath,
139
- write_mode="w",
140
- on_exist=on_exist,
141
- dump_json=False,
142
- encoding=encoding,
143
- )
144
-
145
-
146
- def save_as_json(
147
- content: Any,
148
- filepath: str | Path,
149
- *,
150
- encoding: str = "utf-8",
151
- on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
152
- ) -> Path | None:
153
- """
154
- Save JSON-serializable content to the given file path.
24
+ if not path.exists():
25
+ return path
155
26
 
156
- :param content: Data to write as JSON.
157
- :param filepath: Destination file path.
158
- :param encoding: Text encoding to use (default: 'utf-8').
159
- :param on_exist: How to handle existing files: 'overwrite', 'skip', or 'rename'.
160
- :return: Path if writing succeeds, None otherwise.
161
- """
162
- return _write_file(
163
- content=content,
164
- filepath=filepath,
165
- write_mode="w",
166
- on_exist=on_exist,
167
- dump_json=True,
168
- encoding=encoding,
169
- )
27
+ stem = path.stem
28
+ suffix = path.suffix
170
29
 
30
+ for counter in range(1, max_tries + 1):
31
+ candidate = path.with_name(f"{stem}_{counter}{suffix}")
32
+ if not candidate.exists():
33
+ return candidate
171
34
 
172
- def read_text_file(filepath: str | Path, encoding: str = "utf-8") -> str | None:
173
- """
174
- Read a UTF-8 text file.
35
+ # fallback: append a random/unique suffix
36
+ import uuid
175
37
 
176
- :param filepath: Path to file.
177
- :param encoding: Encoding to use.
178
- :return: Text content or None on failure.
179
- """
180
- path = Path(filepath)
181
- try:
182
- return path.read_text(encoding=encoding)
183
- except Exception as e:
184
- logger.warning("[file] Failed to read %r: %s", path, e)
185
- return None
38
+ return path.with_name(f"{stem}_{uuid.uuid4().hex}{suffix}")
186
39
 
187
40
 
188
- def read_json_file(filepath: str | Path, encoding: str = "utf-8") -> Any | None:
41
+ def write_file(
42
+ content: str | bytes,
43
+ filepath: str | Path,
44
+ *,
45
+ on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
46
+ encoding: str = "utf-8",
47
+ ) -> Path:
189
48
  """
190
- Read a JSON file and parse it into Python objects.
49
+ Write content to a file safely with atomic replacement.
191
50
 
192
- :param filepath: Path to file.
193
- :param encoding: Encoding to use.
194
- :return: Python object or None on failure.
51
+ :param content: The content to write; can be text or bytes.
52
+ :param filepath: Destination path.
53
+ :param on_exist: Behavior if file exists.
54
+ :param encoding: Text encoding for writing.
55
+ :return: The final path where the content was written.
56
+ :raise: Any I/O error such as PermissionError or OSError
195
57
  """
196
58
  path = Path(filepath)
197
- try:
198
- return json.loads(path.read_text(encoding=encoding))
199
- except Exception as e:
200
- logger.warning("[file] Failed to read %r: %s", path, e)
201
- return None
202
-
203
-
204
- def read_binary_file(filepath: str | Path) -> bytes | None:
205
- """
206
- Read a binary file and return its content as bytes.
59
+ path = path.with_name(sanitize_filename(path.name))
60
+ path.parent.mkdir(parents=True, exist_ok=True)
207
61
 
208
- :param filepath: Path to file.
209
- :return: Bytes or None on failure.
210
- """
211
- path = Path(filepath)
212
- try:
213
- return path.read_bytes()
214
- except Exception as e:
215
- logger.warning("[file] Failed to read %r: %s", path, e)
216
- return None
62
+ if path.exists():
63
+ match on_exist:
64
+ case "skip":
65
+ return path
66
+ case "rename":
67
+ path = _unique_path(path)
68
+
69
+ write_mode = "wb" if isinstance(content, bytes) else "w"
70
+
71
+ with tempfile.NamedTemporaryFile(
72
+ mode=write_mode,
73
+ encoding=None if "b" in write_mode else encoding,
74
+ newline=None if "b" in write_mode else "\n",
75
+ delete=False,
76
+ dir=path.parent,
77
+ ) as tmp:
78
+ tmp.write(content)
79
+ tmp_path = Path(tmp.name)
80
+ tmp_path.replace(path)
81
+ return path
@@ -14,8 +14,6 @@ __all__ = ["normalize_txt_line_endings"]
14
14
  import logging
15
15
  from pathlib import Path
16
16
 
17
- logger = logging.getLogger(__name__)
18
-
19
17
 
20
18
  def normalize_txt_line_endings(folder_path: str | Path) -> None:
21
19
  """
@@ -28,7 +26,6 @@ def normalize_txt_line_endings(folder_path: str | Path) -> None:
28
26
  """
29
27
  path = Path(folder_path).resolve()
30
28
  if not path.exists() or not path.is_dir():
31
- logger.warning("[file] Invalid folder: %s", path)
32
29
  return
33
30
 
34
31
  count_success, count_fail = 0, 0
@@ -38,13 +35,10 @@ def normalize_txt_line_endings(folder_path: str | Path) -> None:
38
35
  content = txt_file.read_text(encoding="utf-8")
39
36
  normalized = content.replace("\r\n", "\n").replace("\r", "\n")
40
37
  txt_file.write_text(normalized, encoding="utf-8", newline="\n")
41
- logger.debug("[file] Normalized: %s", txt_file)
42
38
  count_success += 1
43
- except (OSError, UnicodeDecodeError) as e:
44
- logger.warning("[file] Failed: %s | %s", txt_file, e)
39
+ except (OSError, UnicodeDecodeError):
45
40
  count_fail += 1
46
41
 
47
- logger.info("[file] Completed. Success: %s, Failed: %s", count_success, count_fail)
48
42
  return
49
43
 
50
44
 
@@ -5,21 +5,13 @@ novel_downloader.utils.file_utils.sanitize
5
5
 
6
6
  Utility functions for cleaning and validating filenames for safe use
7
7
  on different operating systems.
8
-
9
- This module provides a cross-platform `sanitize_filename` function
10
- that replaces or removes illegal characters from filenames, trims
11
- lengths, and avoids reserved names on Windows systems.
12
8
  """
13
9
 
14
10
  __all__ = ["sanitize_filename"]
15
11
 
16
- import logging
17
12
  import os
18
13
  import re
19
14
 
20
- logger = logging.getLogger(__name__)
21
-
22
- # Windows 保留名称列表 (忽略大小写)
23
15
  _WIN_RESERVED_NAMES = {
24
16
  "CON",
25
17
  "PRN",
@@ -40,8 +32,8 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
40
32
 
41
33
  This function checks the operating system environment and applies the appropriate
42
34
  filtering rules:
43
- - On Windows, it replaces characters: <>:"/\\|?*
44
- - On POSIX systems, it replaces the forward slash '/'
35
+ * On Windows, it replaces characters: <>:"/\\|?*
36
+ * On POSIX systems, it replaces the forward slash '/'
45
37
 
46
38
  :param filename: The input filename to sanitize.
47
39
  :param max_length: Optional maximum length of the output filename. Defaults to 255.
@@ -51,7 +43,7 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
51
43
 
52
44
  name = pattern.sub("_", filename).strip(" .")
53
45
 
54
- stem, dot, ext = name.partition(".")
46
+ stem, dot, ext = name.rpartition(".")
55
47
  if os.name == "nt" and stem.upper() in _WIN_RESERVED_NAMES:
56
48
  stem = f"_{stem}"
57
49
  cleaned = f"{stem}{dot}{ext}" if ext else stem
@@ -63,7 +55,4 @@ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
63
55
  else:
64
56
  cleaned = cleaned[:max_length]
65
57
 
66
- if not cleaned:
67
- cleaned = "_untitled"
68
- logger.debug("[file] Sanitized filename: %r -> %r", filename, cleaned)
69
- return cleaned
58
+ return cleaned or "_untitled"
@@ -3,20 +3,11 @@
3
3
  novel_downloader.utils.fontocr
4
4
  ------------------------------
5
5
 
6
- Utilities for font-based OCR, primarily used to decode custom font obfuscation
7
-
8
- Supports:
9
- - Font rendering and perceptual hash matching
10
- - PaddleOCR-based character recognition
11
- - Frequency-based scoring for ambiguous results
12
- - Debugging and font mapping persistence
13
-
14
- Exposes the selected OCR engine version via `FontOCR`.
6
+ Lazy-loading interface for FontOCR. Provides a safe entry point
7
+ to obtain an OCR utility instance if optional dependencies are available.
15
8
  """
16
9
 
17
- __all__ = ["FontOCR"]
18
- __version__ = "3.0"
10
+ __all__ = ["get_font_ocr"]
11
+ __version__ = "4.0"
19
12
 
20
- # from .ocr_v1 import FontOCRV1 as FontOCR
21
- # from .ocr_v2 import FontOCRV2 as FontOCR
22
- from .ocr_v3 import FontOCRV3 as FontOCR
13
+ from .loader import get_font_ocr