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
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.fontocr.core
4
+ -----------------------------------
5
+
6
+ This class provides utility methods for optical character recognition (OCR),
7
+ primarily used for decrypting custom font encryption.
8
+ """
9
+
10
+ import io
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ from fontTools.ttLib import TTFont
17
+ from paddleocr import TextRecognition
18
+ from PIL import Image, ImageDraw, ImageFont
19
+ from PIL.Image import Transpose
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class FontOCR:
25
+ """
26
+ Version 4 of the FontOCR utility.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ model_name: str | None = None,
32
+ model_dir: str | None = None,
33
+ input_shape: tuple[int, int, int] | None = None,
34
+ device: str | None = None,
35
+ precision: str = "fp32",
36
+ cpu_threads: int = 10,
37
+ **kwargs: Any,
38
+ ) -> None:
39
+ """
40
+ Initialize a FontOCR instance.
41
+
42
+ :param model_name: If set to None, PP-OCRv5_server_rec is used.
43
+ :param model_dir: Model storage path.
44
+ :param input_shape: Input image size for the model in the format (C, H, W).
45
+ :param device: Device for inference.
46
+ :param precision: Precision for TensorRT.
47
+ :param cpu_threads: Number of threads to use for inference on CPUs.
48
+ :param kwargs: reserved for future extensions
49
+ """
50
+ self._ocr_model = TextRecognition( # takes 5 ~ 12 sec to init
51
+ model_name=model_name,
52
+ model_dir=model_dir,
53
+ input_shape=input_shape,
54
+ device=device,
55
+ precision=precision,
56
+ cpu_threads=cpu_threads,
57
+ )
58
+
59
+ def predict(
60
+ self,
61
+ images: list[np.ndarray],
62
+ batch_size: int = 1,
63
+ ) -> list[tuple[str, float]]:
64
+ """
65
+ Run OCR on input images.
66
+
67
+ :param images: list of np.ndarray objects to predict
68
+ :param batch_size: batch size for OCR inference (minimum 1)
69
+ :return: list of tuple containing (character, score)
70
+ """
71
+ return [
72
+ (pred.get("rec_text"), pred.get("rec_score"))
73
+ for pred in self._ocr_model.predict(images, batch_size=batch_size)
74
+ ]
75
+
76
+ @staticmethod
77
+ def render_char_image(
78
+ char: str,
79
+ render_font: ImageFont.FreeTypeFont,
80
+ is_reflect: bool = False,
81
+ size: int = 64,
82
+ ) -> Image.Image:
83
+ """
84
+ Render a single character into an RGB square image.
85
+
86
+ :param char: character to render
87
+ :param render_font: FreeTypeFont instance to render with
88
+ :param is_reflect: if True, flip the image horizontally
89
+ :param size: output image size (width and height in pixels)
90
+ :return: rendered PIL.Image in RGB or None if blank
91
+ """
92
+ # img = Image.new("L", (size, size), color=255)
93
+ img = Image.new("RGB", (size, size), color=(255, 255, 255))
94
+ draw = ImageDraw.Draw(img)
95
+ bbox = draw.textbbox((0, 0), char, font=render_font)
96
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
97
+ x = (size - w) // 2 - bbox[0]
98
+ y = (size - h) // 2 - bbox[1]
99
+ draw.text((x, y), char, fill=0, font=render_font)
100
+ if is_reflect:
101
+ img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
102
+
103
+ return img
104
+
105
+ @staticmethod
106
+ def render_char_image_array(
107
+ char: str,
108
+ render_font: ImageFont.FreeTypeFont,
109
+ is_reflect: bool = False,
110
+ size: int = 64,
111
+ ) -> np.ndarray:
112
+ """
113
+ Render a single character into an RGB square image.
114
+
115
+ :param char: character to render
116
+ :param render_font: FreeTypeFont instance to render with
117
+ :param is_reflect: if True, flip the image horizontally
118
+ :param size: output image size (width and height in pixels)
119
+ :return: rendered image as np.ndarray in RGB or None if blank
120
+ """
121
+ # img = Image.new("L", (size, size), color=255)
122
+ img = Image.new("RGB", (size, size), color=(255, 255, 255))
123
+ draw = ImageDraw.Draw(img)
124
+ bbox = draw.textbbox((0, 0), char, font=render_font)
125
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
126
+ x = (size - w) // 2 - bbox[0]
127
+ y = (size - h) // 2 - bbox[1]
128
+ draw.text((x, y), char, fill=0, font=render_font)
129
+ if is_reflect:
130
+ img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
131
+
132
+ return np.array(img)
133
+
134
+ @staticmethod
135
+ def render_text_image(
136
+ text: str,
137
+ font: ImageFont.FreeTypeFont,
138
+ cell_size: int = 64,
139
+ chars_per_line: int = 16,
140
+ ) -> Image.Image:
141
+ """
142
+ Render a string into a image.
143
+ """
144
+ # import textwrap
145
+ # lines = textwrap.wrap(text, width=chars_per_line) or [""]
146
+ lines = [
147
+ text[i : i + chars_per_line] for i in range(0, len(text), chars_per_line)
148
+ ] or [""]
149
+ img_w = cell_size * chars_per_line
150
+ img_h = cell_size * len(lines)
151
+
152
+ # img = Image.new("L", (img_w, img_h), color=255)
153
+ img = Image.new("RGB", (img_w, img_h), color=(255, 255, 255))
154
+ draw = ImageDraw.Draw(img)
155
+ for row, line in enumerate(lines):
156
+ for col, ch in enumerate(line):
157
+ x = (col + 0.5) * cell_size
158
+ y = (row + 0.5) * cell_size
159
+ draw.text((x, y), ch, font=font, fill=0, anchor="mm")
160
+
161
+ return img
162
+
163
+ @staticmethod
164
+ def load_image_array_from_bytes(data: bytes) -> np.ndarray:
165
+ """
166
+ Decode image bytes into an RGB NumPy array.
167
+
168
+ Reads common image formats (e.g. PNG/JPEG/WebP) from an
169
+ in-memory byte buffer using Pillow, converts the image to RGB,
170
+ and returns a NumPy array suitable for OCR inference.
171
+
172
+ :param data: Image file content as raw bytes.
173
+ :return: NumPy array of shape (H, W, 3), dtype=uint8, in RGB order.
174
+ :raises PIL.UnidentifiedImageError, OSError: If input bytes cannot be decoded.
175
+ """
176
+ with Image.open(io.BytesIO(data)) as im:
177
+ im = im.convert("RGB")
178
+ return np.asarray(im)
179
+
180
+ @staticmethod
181
+ def load_render_font(
182
+ font_path: Path | str, char_size: int = 52
183
+ ) -> ImageFont.FreeTypeFont:
184
+ """
185
+ Load a FreeType font face at the given pixel size for rendering helpers.
186
+
187
+ :param font_path: Path to a TTF/OTF font file.
188
+ :param char_size: Target glyph size in pixels (e.g. 52).
189
+ :return: A PIL `ImageFont.FreeTypeFont` instance.
190
+ :raises OSError: If the font file cannot be opened by PIL.
191
+ """
192
+ return ImageFont.truetype(str(font_path), char_size)
193
+
194
+ @staticmethod
195
+ def extract_font_charset(font_path: Path | str) -> set[str]:
196
+ """
197
+ Extract the set of Unicode characters encoded by a TrueType/OpenType font.
198
+
199
+ This reads the font's best available character map (cmap) and returns the
200
+ corresponding set of characters.
201
+
202
+ :param font_path: Path to a TTF/OTF font file.
203
+ :return: A set of Unicode characters present in the font's cmap.
204
+ """
205
+ with TTFont(font_path) as font_ttf:
206
+ cmap = font_ttf.getBestCmap() or {}
207
+
208
+ charset: set[str] = set()
209
+ for cp in cmap:
210
+ # guard against invalid/surrogate code points
211
+ if 0 <= cp <= 0x10FFFF and not (0xD800 <= cp <= 0xDFFF):
212
+ try:
213
+ charset.add(chr(cp))
214
+ except ValueError:
215
+ continue
216
+ return charset
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.fontocr.loader
4
+ -------------------------------------
5
+
6
+ Lazily load the FontOCR class.
7
+ """
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .core import FontOCR
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _FONT_OCR: "FontOCR | None" = None
18
+
19
+
20
+ def get_font_ocr(
21
+ model_name: str | None = None,
22
+ model_dir: str | None = None,
23
+ input_shape: tuple[int, int, int] | None = None,
24
+ ) -> "FontOCR | None":
25
+ """
26
+ Try to initialize and return a singleton FontOCR instance.
27
+ Returns None if FontOCR or its dependencies are not available.
28
+ """
29
+ global _FONT_OCR
30
+ if _FONT_OCR is None:
31
+ try:
32
+ from .core import FontOCR
33
+
34
+ _FONT_OCR = FontOCR(
35
+ model_name=model_name,
36
+ model_dir=model_dir,
37
+ input_shape=input_shape,
38
+ )
39
+ except ImportError:
40
+ logger.warning(
41
+ "FontOCR dependency not available "
42
+ "(paddleocr / numpy / pillow / fonttools). "
43
+ "Font decoding will be skipped."
44
+ )
45
+ return None
46
+ except Exception as e:
47
+ logger.warning("FontOCR initialization failed: %s", e, exc_info=True)
48
+ return None
49
+
50
+ return _FONT_OCR
@@ -4,101 +4,117 @@ novel_downloader.utils.logger
4
4
  -----------------------------
5
5
 
6
6
  Provides a configurable logging setup for Python applications.
7
- Log files are rotated daily and named with the given logger name and current date.
8
7
  """
9
8
 
9
+ from __future__ import annotations
10
+
10
11
  __all__ = ["setup_logging"]
11
12
 
12
13
  import logging
13
- 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
- from .constants import LOGGER_DIR, LOGGER_NAME
17
+ from .constants import LOGGER_DIR, PACKAGE_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,
25
23
  "ERROR": logging.ERROR,
26
24
  }
25
+ _MUTE_LOGGERS: set[str] = {
26
+ "fontTools.ttLib.tables._p_o_s_t",
27
+ }
28
+
29
+
30
+ def _normalize_level(level: int | str) -> int:
31
+ if isinstance(level, int):
32
+ return level
33
+ if isinstance(level, str):
34
+ lvl = LOG_LEVELS.get(level.upper())
35
+ if isinstance(lvl, int):
36
+ return lvl
37
+ return logging.INFO
27
38
 
28
39
 
29
40
  def setup_logging(
30
- log_filename_prefix: str | None = None,
31
- log_level: LogLevel | None = None,
41
+ log_filename: str | None = None,
42
+ console_level: int | str = "INFO",
43
+ file_level: int | str = "DEBUG",
32
44
  log_dir: str | Path | None = None,
45
+ *,
46
+ console: bool = True,
47
+ file: bool = True,
48
+ backup_count: int = 7,
49
+ when: str = "midnight",
33
50
  ) -> logging.Logger:
34
51
  """
35
- Create and configure a logger for both console and rotating file output.
52
+ Create and configure a package logger with optional console and file handlers.
36
53
 
37
- :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.
54
+ :param log_filename: Base log file name (without date suffix).
55
+ :param console_level: Minimum level for the console handler (string or int).
56
+ :param file_level: Minimum level for the file handler (string or int).
42
57
  :param log_dir: Directory where log files will be saved.
43
- Defaults to "./logs" if not specified.
44
- :return: A fully configured logger instance.
58
+ :param console: Add a console handler.
59
+ :param file: Add a file handler.
60
+ :param backup_count: How many rotated files to keep.
61
+ :param when: Rotation interval for TimedRotatingFileHandler (e.g., "midnight").
62
+ :return: The configured logger.
45
63
  """
46
- ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
47
- ft_logger.setLevel(logging.ERROR)
48
- ft_logger.propagate = False
49
-
50
- # 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
- )
57
-
58
- # Resolve log file path
59
- log_path = Path(log_dir) if log_dir else LOGGER_DIR
60
- log_path.mkdir(parents=True, exist_ok=True)
64
+ # Tame noisy third-party loggers
65
+ for name in _MUTE_LOGGERS:
66
+ ml = logging.getLogger(name)
67
+ ml.setLevel(logging.ERROR)
68
+ ml.propagate = False
61
69
 
62
- # Resolve log file name
63
- if not log_filename_prefix:
64
- log_filename_prefix = LOGGER_NAME
65
- date_str = datetime.now().strftime("%Y-%m-%d")
66
- log_filename = log_path / f"{log_filename_prefix}_{date_str}.log"
67
-
68
- # Create or retrieve logger
69
- logger = logging.getLogger()
70
- logger.setLevel(logging.DEBUG) # Capture everything, filter by handlers
70
+ logger = logging.getLogger(PACKAGE_NAME)
71
+ logger.setLevel(logging.DEBUG)
72
+ logger.propagate = False # otherwise may affected by PaddleOCR
71
73
 
72
74
  # Clear existing handlers to avoid duplicate logs
73
75
  if logger.hasHandlers():
74
76
  logger.handlers.clear()
75
77
 
76
- # File handler: rotates at midnight, keeps 7 days of logs
77
- file_handler = TimedRotatingFileHandler(
78
- filename=str(log_filename),
79
- when="midnight",
80
- interval=1,
81
- backupCount=7,
82
- encoding="utf-8",
83
- utc=False,
84
- )
85
- file_formatter = logging.Formatter(
86
- fmt="%(asctime)s [%(levelname)s] %(name)s.%(funcName)s: %(message)s",
87
- datefmt="%Y-%m-%d %H:%M:%S",
88
- )
89
- file_handler.setFormatter(file_formatter)
90
- file_handler.setLevel(logging.DEBUG)
91
- logger.addHandler(file_handler)
78
+ # File handler (rotates daily)
79
+ if file:
80
+ file_level = _normalize_level(file_level)
81
+
82
+ base_dir = Path(log_dir) if log_dir else LOGGER_DIR
83
+ base_dir.mkdir(parents=True, exist_ok=True)
84
+ base_name = log_filename or PACKAGE_NAME
85
+ log_path = base_dir / f"{base_name}.log"
86
+
87
+ fh = TimedRotatingFileHandler(
88
+ filename=log_path,
89
+ when=when,
90
+ interval=1,
91
+ backupCount=backup_count,
92
+ encoding="utf-8",
93
+ utc=False,
94
+ delay=True,
95
+ )
96
+
97
+ file_formatter = logging.Formatter(
98
+ fmt="%(asctime)s [%(levelname)s] %(name)s.%(funcName)s: %(message)s",
99
+ datefmt="%Y-%m-%d %H:%M:%S",
100
+ )
101
+ fh.setFormatter(file_formatter)
102
+ fh.setLevel(file_level)
103
+ logger.addHandler(fh)
104
+
105
+ print(f"Logging to {log_path}")
92
106
 
93
107
  # Console handler
94
- console_handler = logging.StreamHandler()
95
- console_formatter = logging.Formatter(
96
- fmt="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
97
- )
98
- console_handler.setFormatter(console_formatter)
99
- console_handler.setLevel(console_level)
100
- logger.addHandler(console_handler)
101
-
102
- print(f"Logging to {log_path}")
108
+ if console:
109
+ console_level = _normalize_level(console_level)
110
+
111
+ console_handler = logging.StreamHandler()
112
+ console_formatter = logging.Formatter(
113
+ fmt="%(asctime)s [%(levelname)s] %(message)s",
114
+ datefmt="%H:%M:%S",
115
+ )
116
+ console_handler.setFormatter(console_formatter)
117
+ console_handler.setLevel(console_level)
118
+ logger.addHandler(console_handler)
103
119
 
104
120
  return logger
@@ -8,7 +8,6 @@ Utilities for handling HTTP requests and downloading remote resources.
8
8
 
9
9
  __all__ = ["download"]
10
10
 
11
- import logging
12
11
  from pathlib import Path
13
12
  from typing import Literal
14
13
  from urllib.parse import unquote, urlparse
@@ -19,10 +18,7 @@ from urllib3.util.retry import Retry
19
18
 
20
19
  from .constants import DEFAULT_HEADERS
21
20
  from .file_utils import sanitize_filename
22
- from .file_utils.io import _get_non_conflicting_path, _write_file
23
-
24
- logger = logging.getLogger(__name__)
25
- _DEFAULT_CHUNK_SIZE = 8192 # 8KB per chunk for streaming downloads
21
+ from .file_utils.io import _unique_path, write_file
26
22
 
27
23
 
28
24
  def _normalize_url(url: str) -> str:
@@ -37,8 +33,8 @@ def _normalize_url(url: str) -> str:
37
33
 
38
34
 
39
35
  def _build_filepath(
40
- folder: Path,
41
36
  url: str,
37
+ folder: Path,
42
38
  filename: str | None,
43
39
  default_suffix: str,
44
40
  on_exist: Literal["overwrite", "skip", "rename"],
@@ -48,20 +44,18 @@ def _build_filepath(
48
44
 
49
45
  raw_name = filename or url_path.name or "unnamed"
50
46
  name = sanitize_filename(raw_name)
51
- suffix = default_suffix or url_path.suffix
52
- if suffix and not suffix.startswith("."):
53
- suffix = "." + suffix
54
47
 
55
- file_path = folder / name
56
- if not file_path.suffix and suffix:
57
- file_path = file_path.with_suffix(suffix)
48
+ if "." not in name and (url_path.suffix or default_suffix):
49
+ name += url_path.suffix or default_suffix
58
50
 
51
+ file_path = folder / name
59
52
  if on_exist == "rename":
60
- file_path = _get_non_conflicting_path(file_path)
53
+ file_path = _unique_path(file_path)
54
+
61
55
  return file_path
62
56
 
63
57
 
64
- def _make_session(
58
+ def _new_session(
65
59
  retries: int,
66
60
  backoff: float,
67
61
  headers: dict[str, str] | None,
@@ -72,7 +66,7 @@ def _make_session(
72
66
  retry = Retry(
73
67
  total=retries,
74
68
  backoff_factor=backoff,
75
- status_forcelist=[429, 500, 502, 503, 504],
69
+ status_forcelist=[413, 429, 500, 502, 503, 504],
76
70
  allowed_methods={"GET", "HEAD", "OPTIONS"},
77
71
  )
78
72
  adapter = HTTPAdapter(max_retries=retry)
@@ -90,10 +84,8 @@ def download(
90
84
  retries: int = 3,
91
85
  backoff: float = 0.5,
92
86
  headers: dict[str, str] | None = None,
93
- stream: bool = False,
94
87
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
95
88
  default_suffix: str = "",
96
- chunk_size: int = _DEFAULT_CHUNK_SIZE,
97
89
  ) -> Path | None:
98
90
  """
99
91
  Download a URL to disk, with retries, optional rename/skip, and cleanup on failure.
@@ -105,10 +97,8 @@ def download(
105
97
  :param retries: GET retry count.
106
98
  :param backoff: exponential backoff base.
107
99
  :param headers: optional headers.
108
- :param stream: Whether to stream the response.
109
100
  :param on_exist: if 'skip', return filepath; if 'rename', auto-rename.
110
101
  :param default_suffix: used if no suffix in URL or filename.
111
- :param chunk_size: streaming chunk size.
112
102
  :return: path to the downloaded file.
113
103
  """
114
104
  url = _normalize_url(url)
@@ -117,8 +107,8 @@ def download(
117
107
  folder.mkdir(parents=True, exist_ok=True)
118
108
 
119
109
  save_path = _build_filepath(
120
- folder,
121
110
  url,
111
+ folder,
122
112
  filename,
123
113
  default_suffix,
124
114
  on_exist,
@@ -126,34 +116,20 @@ def download(
126
116
 
127
117
  # Handle existing file
128
118
  if save_path.exists() and on_exist == "skip":
129
- logger.debug("Skipping download; file exists: %s", save_path)
130
119
  return save_path
131
120
 
132
- with _make_session(retries, backoff, headers) as session:
121
+ with _new_session(retries, backoff, headers) as session:
133
122
  try:
134
- resp = session.get(url, timeout=timeout, stream=stream)
123
+ resp = session.get(url, timeout=timeout)
135
124
  resp.raise_for_status()
136
- except Exception as e:
137
- logger.warning("[download] request failed: %s", e)
138
- return None
139
125
 
140
- # Write to disk
141
- if stream:
142
- try:
143
- with open(save_path, "wb") as f:
144
- for chunk in resp.iter_content(chunk_size=chunk_size):
145
- if chunk:
146
- f.write(chunk)
147
- return save_path
148
- except Exception as e:
149
- logger.warning("[download] write failed: %s", e)
150
- save_path.unlink(missing_ok=True)
151
- return None
152
- else:
153
- return _write_file(
126
+ # Write to disk
127
+ return write_file(
154
128
  content=resp.content,
155
129
  filepath=save_path,
156
- write_mode="wb",
157
130
  on_exist=on_exist,
158
131
  )
132
+ except Exception:
133
+ return None
134
+
159
135
  return None