novel-downloader 1.5.0__py3-none-any.whl → 2.0.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 (241) 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 +77 -64
  6. novel_downloader/cli/export.py +16 -20
  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 +65 -105
  12. novel_downloader/config/{loader.py → file_io.py} +53 -26
  13. novel_downloader/core/__init__.py +1 -0
  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 +14 -9
  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 +17 -11
  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} +46 -39
  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 +4 -17
  79. novel_downloader/core/interfaces/parser.py +5 -6
  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 +63 -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 +61 -66
  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/book_info_parser.py +58 -59
  100. novel_downloader/core/parsers/qidian/chapter_encrypted.py +272 -330
  101. novel_downloader/core/parsers/qidian/chapter_normal.py +24 -55
  102. novel_downloader/core/parsers/qidian/main_parser.py +11 -38
  103. novel_downloader/core/parsers/qidian/utils/__init__.py +1 -0
  104. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
  105. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
  106. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -4
  107. novel_downloader/core/parsers/quanben5.py +103 -0
  108. novel_downloader/core/parsers/registry.py +5 -16
  109. novel_downloader/core/parsers/sfacg.py +38 -45
  110. novel_downloader/core/parsers/shencou.py +215 -0
  111. novel_downloader/core/parsers/shuhaige.py +111 -0
  112. novel_downloader/core/parsers/tongrenquan.py +116 -0
  113. novel_downloader/core/parsers/ttkan.py +132 -0
  114. novel_downloader/core/parsers/wanbengo.py +191 -0
  115. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  116. novel_downloader/core/parsers/xiguashuwu.py +435 -0
  117. novel_downloader/core/parsers/xs63b.py +161 -0
  118. novel_downloader/core/parsers/xshbook.py +134 -0
  119. novel_downloader/core/parsers/yamibo.py +87 -131
  120. novel_downloader/core/parsers/yibige.py +166 -0
  121. novel_downloader/core/searchers/__init__.py +34 -3
  122. novel_downloader/core/searchers/aaatxt.py +107 -0
  123. novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
  124. novel_downloader/core/searchers/base.py +112 -36
  125. novel_downloader/core/searchers/dxmwx.py +105 -0
  126. novel_downloader/core/searchers/eightnovel.py +84 -0
  127. novel_downloader/core/searchers/esjzone.py +43 -25
  128. novel_downloader/core/searchers/hetushu.py +92 -0
  129. novel_downloader/core/searchers/i25zw.py +93 -0
  130. novel_downloader/core/searchers/ixdzs8.py +107 -0
  131. novel_downloader/core/searchers/jpxs123.py +107 -0
  132. novel_downloader/core/searchers/piaotia.py +100 -0
  133. novel_downloader/core/searchers/qbtr.py +106 -0
  134. novel_downloader/core/searchers/qianbi.py +74 -40
  135. novel_downloader/core/searchers/quanben5.py +144 -0
  136. novel_downloader/core/searchers/registry.py +24 -8
  137. novel_downloader/core/searchers/shuhaige.py +124 -0
  138. novel_downloader/core/searchers/tongrenquan.py +110 -0
  139. novel_downloader/core/searchers/ttkan.py +92 -0
  140. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  141. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  142. novel_downloader/core/searchers/xs63b.py +104 -0
  143. novel_downloader/locales/en.json +31 -82
  144. novel_downloader/locales/zh.json +32 -83
  145. novel_downloader/models/__init__.py +21 -22
  146. novel_downloader/models/book.py +44 -0
  147. novel_downloader/models/config.py +4 -37
  148. novel_downloader/models/login.py +1 -1
  149. novel_downloader/models/search.py +5 -0
  150. novel_downloader/resources/config/settings.toml +8 -70
  151. novel_downloader/resources/json/xiguashuwu.json +718 -0
  152. novel_downloader/utils/__init__.py +13 -22
  153. novel_downloader/utils/chapter_storage.py +3 -2
  154. novel_downloader/utils/constants.py +4 -29
  155. novel_downloader/utils/cookies.py +6 -18
  156. novel_downloader/utils/crypto_utils/__init__.py +13 -0
  157. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  158. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  159. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  160. novel_downloader/utils/{crypto_utils.py → crypto_utils/rc4.py} +3 -10
  161. novel_downloader/utils/epub/__init__.py +1 -1
  162. novel_downloader/utils/epub/constants.py +57 -16
  163. novel_downloader/utils/epub/documents.py +88 -194
  164. novel_downloader/utils/epub/models.py +0 -14
  165. novel_downloader/utils/epub/utils.py +63 -96
  166. novel_downloader/utils/file_utils/__init__.py +2 -23
  167. novel_downloader/utils/file_utils/io.py +3 -113
  168. novel_downloader/utils/file_utils/sanitize.py +0 -4
  169. novel_downloader/utils/fontocr.py +207 -0
  170. novel_downloader/utils/logger.py +8 -16
  171. novel_downloader/utils/network.py +2 -2
  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/time_utils/__init__.py +5 -11
  176. novel_downloader/utils/time_utils/datetime_utils.py +20 -29
  177. novel_downloader/utils/time_utils/sleep_utils.py +4 -8
  178. novel_downloader/web/__init__.py +13 -0
  179. novel_downloader/web/components/__init__.py +11 -0
  180. novel_downloader/web/components/navigation.py +35 -0
  181. novel_downloader/web/main.py +66 -0
  182. novel_downloader/web/pages/__init__.py +17 -0
  183. novel_downloader/web/pages/download.py +78 -0
  184. novel_downloader/web/pages/progress.py +147 -0
  185. novel_downloader/web/pages/search.py +329 -0
  186. novel_downloader/web/services/__init__.py +17 -0
  187. novel_downloader/web/services/client_dialog.py +164 -0
  188. novel_downloader/web/services/cred_broker.py +113 -0
  189. novel_downloader/web/services/cred_models.py +35 -0
  190. novel_downloader/web/services/task_manager.py +264 -0
  191. novel_downloader-2.0.0.dist-info/METADATA +171 -0
  192. novel_downloader-2.0.0.dist-info/RECORD +210 -0
  193. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/entry_points.txt +1 -1
  194. novel_downloader/core/downloaders/biquge.py +0 -29
  195. novel_downloader/core/downloaders/esjzone.py +0 -29
  196. novel_downloader/core/downloaders/linovelib.py +0 -29
  197. novel_downloader/core/downloaders/sfacg.py +0 -29
  198. novel_downloader/core/downloaders/yamibo.py +0 -29
  199. novel_downloader/core/exporters/biquge.py +0 -22
  200. novel_downloader/core/exporters/esjzone.py +0 -22
  201. novel_downloader/core/exporters/qianbi.py +0 -22
  202. novel_downloader/core/exporters/sfacg.py +0 -22
  203. novel_downloader/core/exporters/yamibo.py +0 -22
  204. novel_downloader/core/fetchers/base/__init__.py +0 -14
  205. novel_downloader/core/fetchers/base/browser.py +0 -422
  206. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  207. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  208. novel_downloader/core/fetchers/esjzone/browser.py +0 -209
  209. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  210. novel_downloader/core/fetchers/linovelib/browser.py +0 -198
  211. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  212. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  213. novel_downloader/core/fetchers/qidian/browser.py +0 -326
  214. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  215. novel_downloader/core/fetchers/sfacg/browser.py +0 -194
  216. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  217. novel_downloader/core/fetchers/yamibo/browser.py +0 -234
  218. novel_downloader/core/parsers/biquge.py +0 -139
  219. novel_downloader/models/chapter.py +0 -25
  220. novel_downloader/models/types.py +0 -13
  221. novel_downloader/tui/__init__.py +0 -7
  222. novel_downloader/tui/app.py +0 -32
  223. novel_downloader/tui/main.py +0 -17
  224. novel_downloader/tui/screens/__init__.py +0 -14
  225. novel_downloader/tui/screens/home.py +0 -198
  226. novel_downloader/tui/screens/login.py +0 -74
  227. novel_downloader/tui/styles/home_layout.tcss +0 -79
  228. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  229. novel_downloader/utils/cache.py +0 -24
  230. novel_downloader/utils/fontocr/__init__.py +0 -22
  231. novel_downloader/utils/fontocr/hash_store.py +0 -280
  232. novel_downloader/utils/fontocr/hash_utils.py +0 -103
  233. novel_downloader/utils/fontocr/model_loader.py +0 -69
  234. novel_downloader/utils/fontocr/ocr_v1.py +0 -315
  235. novel_downloader/utils/fontocr/ocr_v2.py +0 -764
  236. novel_downloader/utils/fontocr/ocr_v3.py +0 -744
  237. novel_downloader-1.5.0.dist-info/METADATA +0 -196
  238. novel_downloader-1.5.0.dist-info/RECORD +0 -164
  239. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/WHEEL +0 -0
  240. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
  241. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
@@ -10,14 +10,12 @@ Pure utility functions for EPUB assembly, including:
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
 
@@ -67,42 +65,43 @@ def build_book_intro(
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(
@@ -122,77 +121,45 @@ def build_volume_intro(
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,21 +3,10 @@
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
11
  import json
23
12
  import logging
@@ -46,7 +35,7 @@ def _get_non_conflicting_path(path: Path) -> Path:
46
35
  return new_path
47
36
 
48
37
 
49
- def _write_file(
38
+ def write_file(
50
39
  content: str | bytes | dict[Any, Any] | list[Any] | Any,
51
40
  filepath: str | Path,
52
41
  write_mode: str = "w",
@@ -115,102 +104,3 @@ def _write_file(
115
104
  except Exception as exc:
116
105
  logger.warning("[file] Error writing %r: %s", path, exc)
117
106
  return None
118
-
119
-
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:
127
- """
128
- Save plain text content to the given file path.
129
-
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.
135
- """
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.
155
-
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
- )
170
-
171
-
172
- def read_text_file(filepath: str | Path, encoding: str = "utf-8") -> str | None:
173
- """
174
- Read a UTF-8 text file.
175
-
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
186
-
187
-
188
- def read_json_file(filepath: str | Path, encoding: str = "utf-8") -> Any | None:
189
- """
190
- Read a JSON file and parse it into Python objects.
191
-
192
- :param filepath: Path to file.
193
- :param encoding: Encoding to use.
194
- :return: Python object or None on failure.
195
- """
196
- 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.
207
-
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
@@ -5,10 +5,6 @@ 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"]
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.fontocr
4
+ ------------------------------
5
+
6
+ This class provides utility methods for optical character recognition (OCR),
7
+ primarily used for decrypting custom font encryption.
8
+ """
9
+
10
+ __all__ = [
11
+ "FontOCR",
12
+ "get_font_ocr",
13
+ ]
14
+ __version__ = "4.0"
15
+
16
+ import logging
17
+ from collections.abc import Generator
18
+ from typing import Any, TypeVar
19
+
20
+ import numpy as np
21
+ from paddleocr import TextRecognition # takes 5 ~ 12 sec to init
22
+ from PIL import Image, ImageDraw, ImageFont
23
+ from PIL.Image import Transpose
24
+
25
+ T = TypeVar("T")
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class FontOCR:
30
+ """
31
+ Version 4 of the FontOCR utility.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ model_name: str | None = None,
37
+ model_dir: str | None = None,
38
+ input_shape: tuple[int, int, int] | None = None,
39
+ device: str | None = None,
40
+ precision: str = "fp32",
41
+ cpu_threads: int = 10,
42
+ batch_size: int = 32,
43
+ threshold: float = 0.0,
44
+ **kwargs: Any,
45
+ ) -> None:
46
+ """
47
+ Initialize a FontOCR instance.
48
+
49
+ :param batch_size: batch size for OCR inference (minimum 1)
50
+ :param ocr_weight: weight factor for OCR-based prediction scores
51
+ :param vec_weight: weight factor for vector-based similarity scores
52
+ :param threshold: minimum confidence threshold for predictions [0.0-1.0]
53
+ :param kwargs: reserved for future extensions
54
+ """
55
+ self._batch_size = batch_size
56
+ self._threshold = threshold
57
+ self._ocr_model = TextRecognition(
58
+ model_name=model_name,
59
+ model_dir=model_dir,
60
+ input_shape=input_shape,
61
+ device=device,
62
+ precision=precision,
63
+ cpu_threads=cpu_threads,
64
+ )
65
+
66
+ def predict(
67
+ self,
68
+ images: list[np.ndarray],
69
+ top_k: int = 1,
70
+ ) -> list[list[tuple[str, float]]]:
71
+ """
72
+ Run OCR on input images.
73
+
74
+ :param images: list of np.ndarray objects to predict
75
+ :param top_k: number of top candidates to return per image
76
+ :return: list of lists containing (character, score)
77
+ """
78
+ return [
79
+ [(pred.get("rec_text"), pred.get("rec_score"))]
80
+ for pred in self._ocr_model.predict(images, batch_size=self._batch_size)
81
+ ]
82
+
83
+ @staticmethod
84
+ def render_char_image(
85
+ char: str,
86
+ render_font: ImageFont.FreeTypeFont,
87
+ is_reflect: bool = False,
88
+ size: int = 64,
89
+ ) -> Image.Image | None:
90
+ """
91
+ Render a single character into an RGB square image.
92
+
93
+ :param char: character to render
94
+ :param render_font: FreeTypeFont instance to render with
95
+ :param is_reflect: if True, flip the image horizontally
96
+ :param size: output image size (width and height in pixels)
97
+ :return: rendered PIL.Image in RGB or None if blank
98
+ """
99
+ # img = Image.new("L", (size, size), color=255)
100
+ img = Image.new("RGB", (size, size), color=(255, 255, 255))
101
+ draw = ImageDraw.Draw(img)
102
+ bbox = draw.textbbox((0, 0), char, font=render_font)
103
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
104
+ x = (size - w) // 2 - bbox[0]
105
+ y = (size - h) // 2 - bbox[1]
106
+ draw.text((x, y), char, fill=0, font=render_font)
107
+ if is_reflect:
108
+ img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
109
+
110
+ img_np = np.array(img)
111
+ if np.unique(img_np).size == 1:
112
+ return None
113
+
114
+ return img
115
+
116
+ @staticmethod
117
+ def render_char_image_array(
118
+ char: str,
119
+ render_font: ImageFont.FreeTypeFont,
120
+ is_reflect: bool = False,
121
+ size: int = 64,
122
+ ) -> np.ndarray | None:
123
+ """
124
+ Render a single character into an RGB square image.
125
+
126
+ :param char: character to render
127
+ :param render_font: FreeTypeFont instance to render with
128
+ :param is_reflect: if True, flip the image horizontally
129
+ :param size: output image size (width and height in pixels)
130
+ :return: rendered image as np.ndarray in RGB or None if blank
131
+ """
132
+ # img = Image.new("L", (size, size), color=255)
133
+ img = Image.new("RGB", (size, size), color=(255, 255, 255))
134
+ draw = ImageDraw.Draw(img)
135
+ bbox = draw.textbbox((0, 0), char, font=render_font)
136
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
137
+ x = (size - w) // 2 - bbox[0]
138
+ y = (size - h) // 2 - bbox[1]
139
+ draw.text((x, y), char, fill=0, font=render_font)
140
+ if is_reflect:
141
+ img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
142
+
143
+ img_np = np.array(img)
144
+ if np.unique(img_np).size == 1:
145
+ return None
146
+
147
+ return img_np
148
+
149
+ @staticmethod
150
+ def render_text_image(
151
+ text: str,
152
+ font: ImageFont.FreeTypeFont,
153
+ cell_size: int = 64,
154
+ chars_per_line: int = 16,
155
+ ) -> Image.Image:
156
+ """
157
+ Render a string into a image.
158
+ """
159
+ # import textwrap
160
+ # lines = textwrap.wrap(text, width=chars_per_line) or [""]
161
+ lines = [
162
+ text[i : i + chars_per_line] for i in range(0, len(text), chars_per_line)
163
+ ] or [""]
164
+ img_w = cell_size * chars_per_line
165
+ img_h = cell_size * len(lines)
166
+
167
+ # img = Image.new("L", (img_w, img_h), color=255)
168
+ img = Image.new("RGB", (img_w, img_h), color=(255, 255, 255))
169
+ draw = ImageDraw.Draw(img)
170
+ for row, line in enumerate(lines):
171
+ for col, ch in enumerate(line):
172
+ x = (col + 0.5) * cell_size
173
+ y = (row + 0.5) * cell_size
174
+ draw.text((x, y), ch, font=font, fill=0, anchor="mm")
175
+
176
+ return img
177
+
178
+ @staticmethod
179
+ def _chunked(seq: list[T], size: int) -> Generator[list[T], None, None]:
180
+ """
181
+ Yield successive chunks of `seq` of length `size`.
182
+ """
183
+ for i in range(0, len(seq), size):
184
+ yield seq[i : i + size]
185
+
186
+
187
+ _font_ocr: FontOCR | None = None
188
+
189
+
190
+ def get_font_ocr(
191
+ model_name: str | None = None,
192
+ model_dir: str | None = None,
193
+ input_shape: tuple[int, int, int] | None = None,
194
+ batch_size: int = 32,
195
+ ) -> FontOCR:
196
+ """
197
+ Return the singleton FontOCR, initializing it on first use.
198
+ """
199
+ global _font_ocr
200
+ if _font_ocr is None:
201
+ _font_ocr = FontOCR(
202
+ model_name=model_name,
203
+ model_dir=model_dir,
204
+ input_shape=input_shape,
205
+ batch_size=batch_size,
206
+ )
207
+ return _font_ocr
@@ -14,11 +14,9 @@ from datetime import datetime
14
14
  from logging.handlers import TimedRotatingFileHandler
15
15
  from pathlib import Path
16
16
 
17
- from novel_downloader.models import LogLevel
18
-
19
17
  from .constants import LOGGER_DIR, LOGGER_NAME
20
18
 
21
- LOG_LEVELS: dict[LogLevel, int] = {
19
+ LOG_LEVELS: dict[str, int] = {
22
20
  "DEBUG": logging.DEBUG,
23
21
  "INFO": logging.INFO,
24
22
  "WARNING": logging.WARNING,
@@ -28,19 +26,16 @@ LOG_LEVELS: dict[LogLevel, int] = {
28
26
 
29
27
  def setup_logging(
30
28
  log_filename_prefix: str | None = None,
31
- log_level: LogLevel | None = None,
29
+ log_level: str | None = None,
32
30
  log_dir: str | Path | None = None,
33
31
  ) -> logging.Logger:
34
32
  """
35
33
  Create and configure a logger for both console and rotating file output.
36
34
 
37
35
  :param log_filename_prefix: Prefix for the log file name.
38
- If None, will use the last part of `logger_name`.
39
- :param log_level: Minimum log level to show in console:
40
- "DEBUG", "INFO", "WARNING", or "ERROR".
41
- Defaults to "INFO" if not specified.
36
+ :param log_level: Minimum log level to show in console
37
+ ("DEBUG", "INFO", "WARNING", "ERROR")
42
38
  :param log_dir: Directory where log files will be saved.
43
- Defaults to "./logs" if not specified.
44
39
  :return: A fully configured logger instance.
45
40
  """
46
41
  ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
@@ -48,12 +43,8 @@ def setup_logging(
48
43
  ft_logger.propagate = False
49
44
 
50
45
  # Determine console level (default INFO)
51
- level_str: LogLevel = log_level or "INFO"
52
- console_level = LOG_LEVELS.get(level_str)
53
- if console_level is None:
54
- raise ValueError(
55
- f"Invalid log level: {level_str}. Must be one of {list(LOG_LEVELS.keys())}"
56
- )
46
+ level_str: str = log_level or "INFO"
47
+ console_level: int = LOG_LEVELS.get(level_str) or logging.INFO
57
48
 
58
49
  # Resolve log file path
59
50
  log_path = Path(log_dir) if log_dir else LOGGER_DIR
@@ -66,8 +57,9 @@ def setup_logging(
66
57
  log_filename = log_path / f"{log_filename_prefix}_{date_str}.log"
67
58
 
68
59
  # Create or retrieve logger
69
- logger = logging.getLogger()
60
+ logger = logging.getLogger(LOGGER_NAME)
70
61
  logger.setLevel(logging.DEBUG) # Capture everything, filter by handlers
62
+ logger.propagate = False
71
63
 
72
64
  # Clear existing handlers to avoid duplicate logs
73
65
  if logger.hasHandlers():
@@ -19,7 +19,7 @@ from urllib3.util.retry import Retry
19
19
 
20
20
  from .constants import DEFAULT_HEADERS
21
21
  from .file_utils import sanitize_filename
22
- from .file_utils.io import _get_non_conflicting_path, _write_file
22
+ from .file_utils.io import _get_non_conflicting_path, write_file
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
  _DEFAULT_CHUNK_SIZE = 8192 # 8KB per chunk for streaming downloads
@@ -150,7 +150,7 @@ def download(
150
150
  save_path.unlink(missing_ok=True)
151
151
  return None
152
152
  else:
153
- return _write_file(
153
+ return write_file(
154
154
  content=resp.content,
155
155
  filepath=save_path,
156
156
  write_mode="wb",