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
@@ -2,24 +2,22 @@
2
2
  """
3
3
  novel_downloader.utils.state
4
4
  ----------------------------
5
- State management for user preferences and runtime flags.
6
5
 
7
- Supported sections:
8
- - general: global preferences (e.g. language)
9
- - sites: per-site flags & data (e.g. manual_login, cookies)
6
+ State management for user preferences and runtime flags.
10
7
  """
11
8
 
9
+ __all__ = ["StateManager", "state_mgr"]
10
+
12
11
  import json
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
16
- from .constants import STATE_FILE
15
+ from novel_downloader.utils.constants import STATE_FILE
17
16
 
18
17
 
19
18
  class StateManager:
20
19
  """
21
20
  Manages persistent state for user preferences and runtime flags.
22
- Stores data in JSON at STATE_FILE.
23
21
  """
24
22
 
25
23
  def __init__(self, path: Path = STATE_FILE) -> None:
@@ -50,26 +48,6 @@ class StateManager:
50
48
  content = json.dumps(self._data, ensure_ascii=False, indent=2)
51
49
  self._path.write_text(content, encoding="utf-8")
52
50
 
53
- def _parse_cookie_string(self, cookie_str: str) -> dict[str, str]:
54
- """
55
- Parse a Cookie header string into a dict.
56
-
57
- :param cookie_str: e.g. 'k1=v1; k2=v2; k3'
58
- :return: mapping cookie names to values (missing '=' yields empty string)
59
- :rtype: Dict[str, str]
60
- """
61
- cookies: dict[str, str] = {}
62
- for item in cookie_str.split(";"):
63
- item = item.strip()
64
- if not item:
65
- continue
66
- if "=" in item:
67
- k, v = item.split("=", 1)
68
- cookies[k.strip()] = v.strip()
69
- else:
70
- cookies[item] = ""
71
- return cookies
72
-
73
51
  def get_language(self) -> str:
74
52
  """
75
53
  Load the user's language preference, defaulting to 'zh'.
@@ -88,69 +66,5 @@ class StateManager:
88
66
  self._data.setdefault("general", {})["lang"] = lang
89
67
  self._save()
90
68
 
91
- def get_manual_login_flag(self, site: str) -> bool:
92
- """
93
- Retrieve the manual login requirement flag for a specific site.
94
-
95
- :param site: Site identifier (e.g. 'qidian', 'bqg')
96
- :return: True if manual login is required (defaults to True)
97
- """
98
- val = self._data.get("sites", {}).get(site, {}).get("manual_login", True)
99
- return bool(val)
100
-
101
- def set_manual_login_flag(self, site: str, flag: bool) -> None:
102
- """
103
- Set the 'manual_login' flag for a specific site.
104
-
105
- :param flag: True if the site requires manual login.
106
- :param site: Site identifier (e.g. 'qidian', 'bqg')
107
- """
108
- sites = self._data.setdefault("sites", {})
109
- site_data = sites.setdefault(site, {})
110
- site_data["manual_login"] = flag
111
- self._save()
112
-
113
- def get_cookies(self, site: str) -> dict[str, str]:
114
- """
115
- Retrieve the persisted cookies for a specific site.
116
-
117
- :param site: Site identifier (e.g. 'qidian', 'bqg')
118
- :return: A dict mapping cookie names to values. Returns empty dict if not set.
119
- """
120
- cookies = self._data.get("sites", {}).get(site, {}).get("cookies", {})
121
- return {str(k): str(v) for k, v in cookies.items()}
122
-
123
- def set_cookies(self, site: str, cookies: str | dict[str, str]) -> None:
124
- """
125
- Persist (overwrite) the cookies for a specific site.
126
-
127
- :param site: Site identifier (e.g. 'qidian', 'bqg')
128
- :param cookies: Either a dict mapping cookie names to values,
129
- or a string (JSON or 'k=v; k2=v2') to be parsed.
130
- :raises TypeError: if cookies is neither str nor dict
131
- """
132
- # 1) normalize to dict
133
- if isinstance(cookies, dict):
134
- cookies_dict = cookies
135
- elif isinstance(cookies, str):
136
- # try JSON first
137
- try:
138
- parsed = json.loads(cookies)
139
- if isinstance(parsed, dict):
140
- cookies_dict = parsed # OK!
141
- else:
142
- raise ValueError
143
- except Exception:
144
- # fallback to "k=v; k2=v2" format
145
- cookies_dict = self._parse_cookie_string(cookies)
146
- else:
147
- raise TypeError("`cookies` must be a dict or a str")
148
-
149
- # 2) persist
150
- sites = self._data.setdefault("sites", {})
151
- site_data = sites.setdefault(site, {})
152
- site_data["cookies"] = {str(k): str(v) for k, v in cookies_dict.items()}
153
- self._save()
154
-
155
69
 
156
70
  state_mgr = StateManager()
@@ -3,13 +3,7 @@
3
3
  novel_downloader.utils.text_utils
4
4
  ---------------------------------
5
5
 
6
- Utility modules for text formatting, font mapping, cleaning, and diff display.
7
-
8
- Submodules:
9
- - diff_display: Generate inline diffs with aligned character markers
10
- - numeric_conversion: Convert between Chinese and Arabic numerals
11
- - text_cleaner: Text cleaning and normalization utilities
12
- - truncate_utils: Text truncation and content prefix generation
6
+ Utility modules for text formatting, cleaning, and diff display.
13
7
  """
14
8
 
15
9
  __all__ = [
@@ -6,9 +6,7 @@ novel_downloader.utils.text_utils.diff_display
6
6
  Generate inline character-level diff between two strings with visual markers.
7
7
  """
8
8
 
9
- __all__ = [
10
- "diff_inline_display",
11
- ]
9
+ __all__ = ["diff_inline_display"]
12
10
 
13
11
  import difflib
14
12
  import unicodedata
@@ -22,10 +20,10 @@ def _char_width_space(
22
20
 
23
21
  Fullwidth (F) or Wide (W) characters map to `asian_char`, else `normal_char`.
24
22
 
25
- :param c: A single character.
23
+ :param c: A single character.
26
24
  :param normal_char: Replacement for narrow chars (default U+0020).
27
- :param asian_char: Replacement for wide chars (default U+3000).
28
- :return: The appropriate space character.
25
+ :param asian_char: Replacement for wide chars (default U+3000).
26
+ :return: The appropriate space character.
29
27
  """
30
28
  return asian_char if unicodedata.east_asian_width(c) in ("F", "W") else normal_char
31
29
 
@@ -37,7 +35,7 @@ def diff_inline_display(old_str: str, new_str: str) -> str:
37
35
 
38
36
  :param old_str: Original string (prefixed '-' will be trimmed).
39
37
  :param new_str: Modified string (prefixed '+' will be trimmed).
40
- :return: A multiline diff display with aligned markers.
38
+ :return: A multiline diff display with aligned markers.
41
39
  """
42
40
  space_1 = " "
43
41
  space_2 = "\u3000"
@@ -42,7 +42,7 @@ class TextCleaner(Cleaner):
42
42
  TextCleaner removes invisible characters, strips unwanted patterns,
43
43
  and applies literal replacements in a single pass using a combined regex.
44
44
 
45
- For regex that never matches, reference:
45
+ For regex that never matches (r"$^"), reference:
46
46
 
47
47
  https://stackoverflow.com/questions/2930182/regex-to-not-match-anything
48
48
  """
@@ -53,13 +53,14 @@ class TextCleaner(Cleaner):
53
53
  """
54
54
  Initialize TextCleaner with the given configuration.
55
55
 
56
- :param config: TextCleanerConfig instance containing:
56
+ Configuration fields (from ``TextCleanerConfig``):
57
+ * remove_invisible: whether to strip BOM/zero-width chars
58
+ * title_remove_patterns: list of regex patterns to delete from titles
59
+ * content_remove_patterns: list of regex patterns to delete from content
60
+ * title_replacements: dict of literal replacements for titles
61
+ * content_replacements: dict of literal replacements for content
57
62
 
58
- - remove_invisible: whether to strip BOM/zero-width chars
59
- - title_remove_patterns: list of regex patterns to delete from titles
60
- - content_remove_patterns: list of regex patterns to delete from content
61
- - title_replacements: dict of literal replacements for titles
62
- - content_replacements: dict of literal replacements for content
63
+ :param config: A ``TextCleanerConfig`` instance.
63
64
  """
64
65
  self._remove_invisible = config.remove_invisible
65
66
 
@@ -73,20 +74,23 @@ class TextCleaner(Cleaner):
73
74
 
74
75
  # Build a single combined regex for title:
75
76
  # all delete‐patterns OR all escaped replacement‐keys
76
- title_parts = title_remove + [re.escape(k) for k in self._title_repl_map]
77
- title_parts.sort(
78
- key=len, reverse=True
79
- ) # longer first to avoid prefix collisions
80
- title_pattern = "|".join(title_parts) if title_parts else r"$^"
81
- self._title_combined_rx: Pattern[str] = re.compile(title_pattern)
77
+ self._title_combined_rx: re.Pattern[str] | None = None
78
+ if title_remove or self._title_repl_map:
79
+ title_parts = title_remove + [re.escape(k) for k in self._title_repl_map]
80
+ # longer first to avoid prefix collisions
81
+ title_parts.sort(key=len, reverse=True)
82
+ self._title_combined_rx = re.compile("|".join(title_parts))
82
83
 
83
84
  # Build a single combined regex for content (multiline mode)
84
- content_parts = content_remove + [re.escape(k) for k in self._content_repl_map]
85
- content_parts.sort(key=len, reverse=True)
86
- content_pattern = "|".join(content_parts) if content_parts else r"$^"
87
- self._content_combined_rx: Pattern[str] = re.compile(
88
- content_pattern, flags=re.MULTILINE
89
- )
85
+ self._content_combined_rx: re.Pattern[str] | None = None
86
+ if content_remove or self._content_repl_map:
87
+ content_parts = content_remove + [
88
+ re.escape(k) for k in self._content_repl_map
89
+ ]
90
+ content_parts.sort(key=len, reverse=True)
91
+ self._content_combined_rx = re.compile(
92
+ "|".join(content_parts), flags=re.MULTILINE
93
+ )
90
94
 
91
95
  def clean_title(self, text: str) -> str:
92
96
  """
@@ -132,11 +136,11 @@ class TextCleaner(Cleaner):
132
136
  Remove BOM and zero-width/invisible characters from the text.
133
137
 
134
138
  Matches:
135
- - U+FEFF (BOM)
136
- - U+200B ZERO WIDTH SPACE
137
- - U+200C ZERO WIDTH NON-JOINER
138
- - U+200D ZERO WIDTH JOINER
139
- - U+2060 WORD JOINER
139
+ * U+FEFF (BOM)
140
+ * U+200B ZERO WIDTH SPACE
141
+ * U+200C ZERO WIDTH NON-JOINER
142
+ * U+200D ZERO WIDTH JOINER
143
+ * U+2060 WORD JOINER
140
144
 
141
145
  :param text: Input string possibly containing invisible chars.
142
146
  :return: String with those characters stripped.
@@ -146,7 +150,7 @@ class TextCleaner(Cleaner):
146
150
  def _do_clean(
147
151
  self,
148
152
  text: str,
149
- combined_rx: Pattern[str],
153
+ combined_rx: Pattern[str] | None,
150
154
  repl_map: dict[str, str],
151
155
  ) -> str:
152
156
  """
@@ -158,17 +162,22 @@ class TextCleaner(Cleaner):
158
162
  :param repl_map: Mapping from matched token to replacement text.
159
163
  :return: Cleaned text.
160
164
  """
165
+ if not self._remove_invisible and not combined_rx:
166
+ return text.strip()
167
+
161
168
  # Strip invisible chars if configured
162
169
  if self._remove_invisible:
163
170
  text = self._remove_bom_and_invisible(text)
164
171
 
165
172
  # Single‐pass removal & replacement
166
- def _sub(match: Match[str]) -> str:
167
- token = match.group(0)
168
- # If token in repl_map -> replacement; else -> delete (empty string)
169
- return repl_map.get(token, "")
173
+ if combined_rx:
174
+
175
+ def _sub(match: Match[str]) -> str:
176
+ # If token in repl_map -> replacement; else -> delete (empty string)
177
+ return repl_map.get(match.group(0), "")
178
+
179
+ text = combined_rx.sub(_sub, text)
170
180
 
171
- text = combined_rx.sub(_sub, text)
172
181
  return text.strip()
173
182
 
174
183
 
@@ -11,8 +11,6 @@ __all__ = [
11
11
  "truncate_half_lines",
12
12
  ]
13
13
 
14
- import math
15
-
16
14
 
17
15
  def content_prefix(
18
16
  text: str,
@@ -41,22 +39,13 @@ def content_prefix(
41
39
 
42
40
  def truncate_half_lines(text: str) -> str:
43
41
  """
44
- Keep the first half of the lines (rounded up), preserving line breaks.
42
+ Keep the first half of the lines.
45
43
 
46
44
  :param text: Full input text
47
45
  :return: Truncated text with first half of lines
48
46
  """
49
47
  lines = text.splitlines()
50
48
  non_empty_lines = [line for line in lines if line.strip()]
51
- keep_count = math.ceil(len(non_empty_lines) / 2)
52
-
53
- result_lines = []
54
- count = 0
55
- for line in lines:
56
- result_lines.append(line)
57
- if line.strip():
58
- count += 1
59
- if count >= keep_count:
60
- break
61
-
49
+ keep_count = (len(non_empty_lines) + 1) // 2
50
+ result_lines = non_empty_lines[:keep_count]
62
51
  return "\n".join(result_lines)
@@ -4,19 +4,13 @@ novel_downloader.utils.time_utils
4
4
  ---------------------------------
5
5
 
6
6
  Utility functions for time and date-related operations.
7
-
8
- Includes:
9
- - calculate_time_difference:
10
- Computes time delta between two timezone-aware datetime strings.
11
- - sleep_with_random_delay:
12
- Sleeps for a random duration, useful for human-like delays or rate limiting.
13
7
  """
14
8
 
15
9
  __all__ = [
16
- "calculate_time_difference",
17
- "async_sleep_with_random_delay",
18
- "sleep_with_random_delay",
10
+ "time_diff",
11
+ "async_jitter_sleep",
12
+ "jitter_sleep",
19
13
  ]
20
14
 
21
- from .datetime_utils import calculate_time_difference
22
- from .sleep_utils import async_sleep_with_random_delay, sleep_with_random_delay
15
+ from .datetime_utils import time_diff
16
+ from .sleep_utils import async_jitter_sleep, jitter_sleep
@@ -4,16 +4,10 @@ novel_downloader.utils.time_utils.datetime_utils
4
4
  ------------------------------------------------
5
5
 
6
6
  Time utility functions for timezone-aware date calculations.
7
-
8
- Includes:
9
- - _parse_utc_offset():
10
- Converts UTC offset string (e.g. 'UTC+8') to a timezone object.
11
- - calculate_time_difference():
12
- Computes timedelta between two datetime strings, with optional timezones.
13
7
  """
14
8
 
15
9
  __all__ = [
16
- "calculate_time_difference",
10
+ "time_diff",
17
11
  ]
18
12
 
19
13
  import logging
@@ -53,7 +47,7 @@ def _parse_utc_offset(tz_str: str) -> timezone:
53
47
  Parse a timezone string like 'UTC+8' or 'UTC-5' into a datetime.timezone object.
54
48
 
55
49
  :param tz_str: Timezone in 'UTC±<hours>' format, e.g. 'UTC', 'UTC+8', 'UTC-05'
56
- :return: Corresponding timezone object
50
+ :return: Corresponding timezone object
57
51
  :raises ValueError: if tz_str is not a valid UTC offset format
58
52
  """
59
53
  tz_str_clean = tz_str.upper().strip()
@@ -74,20 +68,20 @@ def _parse_datetime_flexible(dt_str: str) -> datetime:
74
68
  """
75
69
  Parse a date/time string in any of several common formats:
76
70
 
77
- ISO 8601: 'YYYY-MM-DDTHH:MM:SSZ'
78
- ISO w/ offset: 'YYYY-MM-DDTHH:MM:SS+HH:MM'
79
- 'YYYY-MM-DD HH:MM:SS'
80
- 'YYYY-MM-DD' (time defaults to 00:00:00)
81
- 'YYYY/MM/DD HH:MM:SS'
82
- 'YYYY/MM/DD HH:MM'
83
- 'YYYY/MM/DD'
84
- 'MM/DD/YYYY HH:MM[:SS] AM/PM'
85
- 'MM/DD/YYYY'
86
- 'DD.MM.YYYY HH:MM'
87
- 'DD.MM.YYYY'
71
+ * ISO 8601: 'YYYY-MM-DDTHH:MM:SSZ'
72
+ * ISO w/ offset: 'YYYY-MM-DDTHH:MM:SS+HH:MM'
73
+ * 'YYYY-MM-DD HH:MM:SS'
74
+ * 'YYYY-MM-DD' (time defaults to 00:00:00)
75
+ * 'YYYY/MM/DD HH:MM:SS'
76
+ * 'YYYY/MM/DD HH:MM'
77
+ * 'YYYY/MM/DD'
78
+ * 'MM/DD/YYYY HH:MM[:SS] AM/PM'
79
+ * 'MM/DD/YYYY'
80
+ * 'DD.MM.YYYY HH:MM'
81
+ * 'DD.MM.YYYY'
88
82
 
89
83
  :param dt_str: Date/time string to parse.
90
- :return: A naive datetime object.
84
+ :return: A naive datetime object.
91
85
  :raises ValueError: If dt_str does not match the expected formats.
92
86
  """
93
87
  s = dt_str.strip()
@@ -95,13 +89,10 @@ def _parse_datetime_flexible(dt_str: str) -> datetime:
95
89
  if re.fullmatch(pattern, s):
96
90
  return datetime.strptime(s, fmt)
97
91
 
98
- supported = "\n".join(f" {fmt}" for _, fmt in _DATETIME_FORMATS)
99
- raise ValueError(
100
- f"Invalid date/time format: '{dt_str}'\n" f"Supported formats are:\n{supported}"
101
- )
92
+ raise ValueError(f"Invalid date/time format: '{dt_str}'")
102
93
 
103
94
 
104
- def calculate_time_difference(
95
+ def time_diff(
105
96
  from_time_str: str,
106
97
  tz_str: str = "UTC",
107
98
  to_time_str: str | None = None,
@@ -111,10 +102,10 @@ def calculate_time_difference(
111
102
  Calculate the difference between two datetime values.
112
103
 
113
104
  :param from_time_str: Date-time string "YYYY-MM-DD HH:MM:SS" for the start.
114
- :param tz_str: Timezone of from_time_str, e.g. 'UTC+8'. Defaults to 'UTC'.
115
- :param to_time_str: Optional date-time string for the end; if None, uses now().
116
- :param to_tz_str: Timezone of to_time_str. Defaults to 'UTC'.
117
- :return: Tuple (days, hours, minutes, seconds).
105
+ :param tz_str: Timezone of from_time_str, e.g. 'UTC+8'. Defaults to 'UTC'.
106
+ :param to_time_str: Optional date-time string for the end; if None, uses now().
107
+ :param to_tz_str: Timezone of to_time_str. Defaults to 'UTC'.
108
+ :return: Tuple (days, hours, minutes, seconds).
118
109
  """
119
110
  try:
120
111
  # parse start time
@@ -4,13 +4,9 @@ novel_downloader.utils.time_utils.sleep_utils
4
4
  ---------------------------------------------
5
5
 
6
6
  Utilities for adding randomized delays in scripts and bots.
7
-
8
- Includes:
9
- - sleep_with_random_delay(): Sleep between base and base+spread seconds,
10
- optionally capped with a max_sleep limit.
11
7
  """
12
8
 
13
- __all__ = ["sleep_with_random_delay", "async_sleep_with_random_delay"]
9
+ __all__ = ["jitter_sleep", "async_jitter_sleep"]
14
10
 
15
11
  import asyncio
16
12
  import logging
@@ -20,50 +16,51 @@ import time
20
16
  logger = logging.getLogger(__name__)
21
17
 
22
18
 
23
- def sleep_with_random_delay(
19
+ def _calc_sleep_duration(
24
20
  base: float,
25
- add_spread: float = 0.0,
26
- mul_spread: float = 1.0,
27
- *,
21
+ add_spread: float,
22
+ mul_spread: float,
28
23
  max_sleep: float | None = None,
29
- ) -> None:
24
+ *,
25
+ log_prefix: str = "sleep",
26
+ ) -> float | None:
30
27
  """
31
- Sleep for a random duration by combining multiplicative and additive jitter.
32
-
33
- The total sleep time is computed as:
34
-
35
- duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
28
+ Compute the jittered sleep duration (in seconds) or return None if params invalid.
36
29
 
37
- If `max_sleep` is provided, the duration will be capped at that value.
30
+ duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
38
31
 
39
- :param base: Base sleep time in seconds. Must be >= 0.
40
- :param add_spread: Maximum extra seconds to add after scaling base.
41
- :param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
42
- :param max_sleep: Optional upper limit for the final sleep duration.
32
+ then optionally capped by max_sleep.
43
33
  """
44
- if base < 0 or add_spread < 0 or mul_spread < 0:
34
+ if base < 0 or add_spread < 0 or mul_spread < 1.0:
45
35
  logger.warning(
46
- "[sleep] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
36
+ "[%s] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
37
+ log_prefix,
47
38
  base,
48
39
  add_spread,
49
40
  mul_spread,
50
41
  )
51
- return
42
+ return None
52
43
 
53
- # Calculate the raw duration
54
44
  multiplicative_jitter = random.uniform(1.0, mul_spread)
55
- additive_jitter = random.uniform(0, add_spread)
45
+ additive_jitter = random.uniform(0.0, add_spread)
56
46
  duration = base * multiplicative_jitter + additive_jitter
57
47
 
58
48
  if max_sleep is not None:
59
49
  duration = min(duration, max_sleep)
60
50
 
61
- logger.debug("[time] Sleeping for %.2f seconds", duration)
62
- time.sleep(duration)
63
- return
51
+ logger.debug(
52
+ "[%s] base=%.3f mul=%.3f add=%.3f max=%s -> duration=%.3f",
53
+ log_prefix,
54
+ base,
55
+ multiplicative_jitter,
56
+ additive_jitter,
57
+ max_sleep,
58
+ duration,
59
+ )
60
+ return duration
64
61
 
65
62
 
66
- async def async_sleep_with_random_delay(
63
+ def jitter_sleep(
67
64
  base: float,
68
65
  add_spread: float = 0.0,
69
66
  mul_spread: float = 1.0,
@@ -71,34 +68,43 @@ async def async_sleep_with_random_delay(
71
68
  max_sleep: float | None = None,
72
69
  ) -> None:
73
70
  """
74
- Async sleep for a random duration by combining multiplicative and additive jitter.
75
-
76
- The total sleep time is computed as:
77
-
78
- duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
79
-
80
- If `max_sleep` is provided, the duration will be capped at that value.
71
+ Sleep for a random duration by combining multiplicative and additive jitter.
81
72
 
82
73
  :param base: Base sleep time in seconds. Must be >= 0.
83
74
  :param add_spread: Maximum extra seconds to add after scaling base.
84
75
  :param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
85
76
  :param max_sleep: Optional upper limit for the final sleep duration.
86
77
  """
87
- if base < 0 or add_spread < 0 or mul_spread < 1.0:
88
- logger.warning(
89
- "[async sleep] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
90
- base,
91
- add_spread,
92
- mul_spread,
93
- )
78
+ duration = _calc_sleep_duration(
79
+ base,
80
+ add_spread,
81
+ mul_spread,
82
+ max_sleep,
83
+ log_prefix="sleep",
84
+ )
85
+ if duration is None:
94
86
  return
87
+ time.sleep(duration)
95
88
 
96
- multiplicative_jitter = random.uniform(1.0, mul_spread)
97
- additive_jitter = random.uniform(0, add_spread)
98
- duration = base * multiplicative_jitter + additive_jitter
99
89
 
100
- if max_sleep is not None:
101
- duration = min(duration, max_sleep)
90
+ async def async_jitter_sleep(
91
+ base: float,
92
+ add_spread: float = 0.0,
93
+ mul_spread: float = 1.0,
94
+ *,
95
+ max_sleep: float | None = None,
96
+ ) -> None:
97
+ """
98
+ Async sleep for a random duration by combining multiplicative and additive jitter.
102
99
 
103
- logger.debug("[async time] Sleeping for %.2f seconds", duration)
100
+ :param base: Base sleep time in seconds. Must be >= 0.
101
+ :param add_spread: Maximum extra seconds to add after scaling base.
102
+ :param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
103
+ :param max_sleep: Optional upper limit for the final sleep duration.
104
+ """
105
+ duration = _calc_sleep_duration(
106
+ base, add_spread, mul_spread, max_sleep, log_prefix="async sleep"
107
+ )
108
+ if duration is None:
109
+ return
104
110
  await asyncio.sleep(duration)
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web
4
+ --------------------
5
+
6
+ This module exposes the WEB entry point.
7
+ """
8
+
9
+ __all__ = [
10
+ "web_main",
11
+ ]
12
+
13
+ from .main import web_main
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.components
4
+ -------------------------------
5
+
6
+ Entry point for reusable web UI components
7
+ """
8
+
9
+ __all__ = ["navbar"]
10
+
11
+ from .navigation import navbar
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.components.navigation
4
+ ------------------------------------------
5
+
6
+ A tiny NiceGUI component that renders the app's top navigation bar
7
+ """
8
+
9
+ from nicegui import ui
10
+
11
+
12
+ def navbar(active: str) -> None:
13
+ """
14
+ Render the site-wide navigation header.
15
+
16
+ :param active: Key of the current page to highlight.
17
+ """
18
+ with (
19
+ ui.header().classes("px-3 items-center justify-between bg-primary text-white"),
20
+ ui.row().classes("items-center gap-2 flex-wrap"),
21
+ ):
22
+ _nav_btn("搜索", "/", active == "search", icon="search")
23
+ _nav_btn("下载", "/download", active == "download", icon="download")
24
+ _nav_btn("正在下载", "/progress", active == "progress", icon="cloud_download")
25
+
26
+
27
+ def _nav_btn(label: str, path: str, is_active: bool, icon: str | None = None) -> None:
28
+ if is_active:
29
+ ui.button(label, icon=icon, on_click=lambda: ui.navigate.to(path)).props(
30
+ "unelevated color=white text-color=primary"
31
+ )
32
+ else:
33
+ ui.button(label, icon=icon, on_click=lambda: ui.navigate.to(path)).props(
34
+ "flat text-color=white"
35
+ )