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
@@ -3,13 +3,18 @@
3
3
  novel_downloader.cli.search
4
4
  ---------------------------
5
5
 
6
+ Search across supported sites, let the user pick one result, then
7
+ hand off to the download flow.
6
8
  """
7
9
 
10
+ from __future__ import annotations
11
+
8
12
  import asyncio
9
13
  from argparse import Namespace, _SubParsersAction
10
14
  from collections.abc import Sequence
11
15
  from pathlib import Path
12
16
 
17
+ from novel_downloader.cli import ui
13
18
  from novel_downloader.cli.download import _download
14
19
  from novel_downloader.config import ConfigAdapter, load_config
15
20
  from novel_downloader.core import search
@@ -18,106 +23,98 @@ from novel_downloader.utils.i18n import t
18
23
 
19
24
 
20
25
  def register_search_subcommand(subparsers: _SubParsersAction) -> None: # type: ignore
26
+ """Register the `search` subcommand and its options."""
21
27
  parser = subparsers.add_parser("search", help=t("help_search"))
22
28
 
23
29
  parser.add_argument(
24
- "--site",
25
- "-s",
26
- action="append",
27
- metavar="SITE",
28
- help=t("help_search_sites"),
29
- )
30
- parser.add_argument(
31
- "keyword",
32
- help=t("help_search_keyword"),
30
+ "--site", "-s", action="append", metavar="SITE", help=t("search_sites_help")
33
31
  )
32
+ parser.add_argument("keyword", help=t("search_keyword_help"))
33
+ parser.add_argument("--config", type=str, help=t("help_config"))
34
34
  parser.add_argument(
35
- "--config",
36
- type=str,
37
- help=t("help_config"),
38
- )
39
- parser.add_argument(
40
- "--limit",
41
- "-l",
42
- type=int,
43
- default=10,
44
- metavar="N",
45
- help=t("help_search_limit"),
35
+ "--limit", "-l", type=int, default=20, metavar="N", help=t("search_limit_help")
46
36
  )
47
37
  parser.add_argument(
48
38
  "--site-limit",
49
39
  type=int,
50
40
  default=5,
51
41
  metavar="M",
52
- help=t("help_search_site_limit"),
42
+ help=t("search_site_limit_help"),
43
+ )
44
+ parser.add_argument(
45
+ "--timeout",
46
+ type=float,
47
+ default=5.0,
48
+ metavar="SECS",
49
+ help=t("search_timeout_help", secs="5.0"),
53
50
  )
54
51
 
55
52
  parser.set_defaults(func=handle_search)
56
53
 
57
54
 
58
55
  def handle_search(args: Namespace) -> None:
59
- """
60
- Handler for the `search` subcommand. Loads config, runs the search,
61
- prompts the user to pick one result, then kicks off download.
62
- """
56
+ """Handle the `search` subcommand."""
63
57
  sites: Sequence[str] | None = args.site or None
64
58
  keyword: str = args.keyword
65
59
  overall_limit = max(1, args.limit)
66
60
  per_site_limit = max(1, args.site_limit)
61
+ timeout = max(0.1, float(args.timeout))
67
62
  config_path: Path | None = Path(args.config) if args.config else None
68
63
 
69
64
  try:
70
65
  config_data = load_config(config_path)
71
66
  except Exception as e:
72
- print(t("download_config_load_fail", err=str(e)))
67
+ ui.error(t("download_config_load_fail", err=str(e)))
73
68
  return
74
69
 
75
- results = search(
76
- keyword=keyword,
77
- sites=sites,
78
- limit=overall_limit,
79
- per_site_limit=per_site_limit,
80
- )
70
+ async def _run() -> None:
71
+ results = await search(
72
+ keyword=keyword,
73
+ sites=sites,
74
+ limit=overall_limit,
75
+ per_site_limit=per_site_limit,
76
+ timeout=timeout,
77
+ )
81
78
 
82
- chosen = _prompt_user_select(results)
83
- if chosen is None:
84
- # user cancelled or no valid choice
85
- return
79
+ chosen = _prompt_user_select(results)
80
+ if chosen is None:
81
+ return
82
+
83
+ adapter = ConfigAdapter(config=config_data, site=chosen["site"])
84
+ books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
85
+ await _download(adapter, chosen["site"], books)
86
86
 
87
- adapter = ConfigAdapter(config=config_data, site=chosen["site"])
88
- books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
89
- asyncio.run(_download(adapter, chosen["site"], books))
87
+ asyncio.run(_run())
90
88
 
91
89
 
92
- def _prompt_user_select(
93
- results: Sequence[SearchResult],
94
- max_attempts: int = 3,
95
- ) -> SearchResult | None:
90
+ def _prompt_user_select(results: Sequence[SearchResult]) -> SearchResult | None:
96
91
  """
97
- Display a numbered list of results and prompt the user to pick one.
92
+ Show a Rich table of results and ask the user to pick one by index.
98
93
 
99
- :param results: A list of SearchResult dicts.
100
- :param max_attempts: How many bad inputs to tolerate before giving up.
101
- :return: The chosen SearchResult, or None if cancelled/failed.
94
+ :param results: A sequence of SearchResult dicts.
95
+ :return: The chosen SearchResult, or None if cancelled/no results.
102
96
  """
103
97
  if not results:
104
- print(t("no_results"))
98
+ ui.warn(t("no_results"))
105
99
  return None
106
100
 
107
- # Show choices
108
- for i, r in enumerate(results, start=1):
109
- print(f"[{i}] {r['title']} - {r['author']} ({r['site']}, id: {r['book_id']})")
110
-
111
- attempts = 0
112
- while attempts < max_attempts:
113
- choice = input(t("prompt_select_index")).strip()
114
- if choice == "":
115
- return None
116
- if choice.isdigit():
117
- idx = int(choice)
118
- if 1 <= idx <= len(results):
119
- return results[idx - 1]
120
- print(t("invalid_selection"))
121
- attempts += 1
122
-
123
- return None
101
+ columns = ["#", "Title", "Author", "Latest", "Updated", "Site", "Book ID"]
102
+ rows = []
103
+ for i, r in enumerate(results, 1):
104
+ rows.append(
105
+ [
106
+ str(i),
107
+ r["title"],
108
+ r["author"],
109
+ r["latest_chapter"],
110
+ r["update_date"],
111
+ r["site"],
112
+ r["book_id"],
113
+ ]
114
+ )
115
+ ui.render_table("Search Results", columns, rows)
116
+
117
+ idx = ui.select_index(t("prompt_select_index"), len(results))
118
+ if idx is None:
119
+ return None
120
+ return results[idx - 1]
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.cli.ui
4
+ -----------------------
5
+
6
+ A small set of Rich-based helpers to keep CLI presentation and prompts
7
+ consistent across subcommands.
8
+
9
+ Public API:
10
+ - info, success, warn, error
11
+ - confirm
12
+ - prompt, prompt_password
13
+ - render_table
14
+ - select_index
15
+ - print_progress
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Sequence
21
+
22
+ from rich.console import Console
23
+ from rich.prompt import Confirm, Prompt
24
+ from rich.table import Table
25
+
26
+ _CONSOLE = Console()
27
+
28
+
29
+ def info(message: str) -> None:
30
+ """Print a neutral informational message."""
31
+ _CONSOLE.print(message)
32
+
33
+
34
+ def success(message: str) -> None:
35
+ """Print a success message in a friendly color."""
36
+ _CONSOLE.print(f"[green]{message}[/]")
37
+
38
+
39
+ def warn(message: str) -> None:
40
+ """Print a warning message."""
41
+ _CONSOLE.print(f"[yellow]{message}[/]")
42
+
43
+
44
+ def error(message: str) -> None:
45
+ """Print an error message."""
46
+ _CONSOLE.print(f"[red]{message}[/]")
47
+
48
+
49
+ def confirm(message: str, *, default: bool = False) -> bool:
50
+ """
51
+ Ask a yes/no question.
52
+
53
+ :param message: The question to display (without [y/N] suffix).
54
+ :param default: Default choice (pressing Enter = Yes if True, No if False).
55
+ :return: True if user confirms (Yes), otherwise False.
56
+ """
57
+ try:
58
+ result: bool = Confirm.ask(f"[bold]{message}[/bold]", default=default)
59
+ return result
60
+ except (KeyboardInterrupt, EOFError):
61
+ warn("Cancelled.")
62
+ return False
63
+
64
+
65
+ def prompt(message: str, *, default: str | None = None) -> str:
66
+ """
67
+ Prompt user for a line of text.
68
+
69
+ :param message: Prompt message.
70
+ :param default: Default value if the user presses Enter.
71
+ :return: The user's input.
72
+ """
73
+ try:
74
+ result: str = Prompt.ask(message, default=default or "")
75
+ return result
76
+ except (KeyboardInterrupt, EOFError):
77
+ warn("Cancelled.")
78
+ return default or ""
79
+
80
+
81
+ def prompt_password(message: str) -> str:
82
+ """
83
+ Prompt user for a password/secret value (no echo).
84
+
85
+ :param message: Prompt message.
86
+ :return: The user's input (may be empty).
87
+ """
88
+ try:
89
+ result: str = Prompt.ask(message, password=True)
90
+ return result
91
+ except (KeyboardInterrupt, EOFError):
92
+ warn("Cancelled.")
93
+ return ""
94
+
95
+
96
+ def render_table(
97
+ title: str,
98
+ columns: Sequence[str],
99
+ rows: Iterable[Sequence[str]],
100
+ ) -> None:
101
+ """
102
+ Render a simple full-width table.
103
+
104
+ :param title: Table title.
105
+ :param columns: Column names.
106
+ :param rows: Row data; each row must have the same length as `columns`.
107
+ """
108
+ table = Table(title=title, show_lines=True, expand=True)
109
+ for col in columns:
110
+ table.add_column(col, overflow="fold")
111
+ for row in rows:
112
+ table.add_row(*[str(x) for x in row])
113
+ _CONSOLE.print(table)
114
+
115
+
116
+ def select_index(prompt_text: str, total: int) -> int | None:
117
+ """
118
+ Prompt user to select an index in [1..total]. Empty input cancels.
119
+
120
+ :param prompt_text: Displayed prompt (e.g., 'Select index').
121
+ :param total: Maximum valid index (minimum is 1).
122
+ :return: Selected 1-based index, or None if user cancels.
123
+ """
124
+ if total <= 0:
125
+ return None
126
+ valid_choices = [str(i) for i in range(1, total + 1)]
127
+ choice = Prompt.ask(
128
+ prompt_text,
129
+ choices=valid_choices + [""],
130
+ show_choices=False,
131
+ default="",
132
+ show_default=False,
133
+ ).strip()
134
+ if not choice:
135
+ return None
136
+ return int(choice)
137
+
138
+
139
+ def print_progress(
140
+ done: int,
141
+ total: int,
142
+ *,
143
+ prefix: str = "Progress",
144
+ unit: str = "item",
145
+ ) -> None:
146
+ """
147
+ Print a lightweight progress line.
148
+
149
+ :param done: Completed count.
150
+ :param total: Total count.
151
+ :param prefix: Text prefix shown before numbers.
152
+ :param unit: Logical unit name (e.g., 'item').
153
+ """
154
+ total = max(1, total)
155
+ pct = done / total * 100.0
156
+ _CONSOLE.print(f"[dim]{prefix}[/] {done}/{total} {unit} ({pct:.2f}%)")
@@ -4,17 +4,20 @@ novel_downloader.config
4
4
  -----------------------
5
5
 
6
6
  Unified interface for loading and adapting configuration files.
7
-
8
- This module provides:
9
- - load_config: loads YAML config from file path with fallback support
10
- - ConfigAdapter: maps raw config + site name to structured config models
11
7
  """
12
8
 
13
9
  __all__ = [
10
+ "get_config_value",
14
11
  "load_config",
12
+ "save_config",
15
13
  "save_config_file",
16
14
  "ConfigAdapter",
17
15
  ]
18
16
 
19
17
  from .adapter import ConfigAdapter
20
- from .loader import load_config, save_config_file
18
+ from .file_io import (
19
+ get_config_value,
20
+ load_config,
21
+ save_config,
22
+ save_config_file,
23
+ )
@@ -8,18 +8,19 @@ site name into structured dataclass-based config models.
8
8
  """
9
9
 
10
10
  import json
11
- from typing import Any, cast
11
+ from typing import Any, TypeVar, cast
12
12
 
13
13
  from novel_downloader.models import (
14
14
  BookConfig,
15
15
  DownloaderConfig,
16
16
  ExporterConfig,
17
17
  FetcherConfig,
18
- LogLevel,
19
18
  ParserConfig,
20
19
  TextCleanerConfig,
21
20
  )
22
21
 
22
+ T = TypeVar("T")
23
+
23
24
 
24
25
  class ConfigAdapter:
25
26
  """
@@ -27,129 +28,78 @@ class ConfigAdapter:
27
28
  into structured dataclass configuration models.
28
29
  """
29
30
 
30
- _ALLOWED_LOG_LEVELS: tuple[LogLevel, ...] = (
31
- "DEBUG",
32
- "INFO",
33
- "WARNING",
34
- "ERROR",
35
- )
36
-
37
31
  def __init__(self, config: dict[str, Any], site: str):
38
32
  """
39
33
  Initialize the adapter.
40
34
 
41
35
  :param config: The fully loaded configuration dictionary.
42
- :param site: The current site name (e.g. "qidian").
36
+ :param site: The current site name (e.g. "qidian").
43
37
  """
44
38
  self._config = config
45
39
  self._site = site
40
+ self._site_cfg: dict[str, Any] = self._get_site_cfg()
41
+ self._gen_cfg: dict[str, Any] = config.get("general") or {}
46
42
 
47
43
  def get_fetcher_config(self) -> FetcherConfig:
48
44
  """
49
45
  Build a FetcherConfig from the raw configuration.
50
46
 
51
- Reads from:
52
- - config["general"] for global defaults (e.g. request_interval)
53
- - config["requests"] for HTTP-specific settings (timeouts, retries, etc.)
54
- - site-specific overrides under config["sites"][site]
55
-
56
47
  :return: A FetcherConfig instance with all fields populated.
57
48
  """
58
- gen = self._config.get("general", {})
59
- req = self._config.get("requests", {})
60
- site_cfg = self._get_site_cfg()
61
49
  return FetcherConfig(
62
- request_interval=gen.get("request_interval", 2.0),
63
- retry_times=req.get("retry_times", 3),
64
- backoff_factor=req.get("backoff_factor", 2.0),
65
- timeout=req.get("timeout", 30.0),
66
- max_connections=req.get("max_connections", 10),
67
- max_rps=req.get("max_rps", None),
68
- headless=req.get("headless", False),
69
- disable_images=req.get("disable_images", False),
70
- mode=site_cfg.get("mode", "session"),
71
- proxy=req.get("proxy", None),
72
- user_agent=req.get("user_agent", None),
73
- headers=req.get("headers", None),
74
- browser_type=req.get("browser_type", "chromium"),
75
- verify_ssl=req.get("verify_ssl", True),
50
+ request_interval=self._get_gen_cfg("request_interval", 2.0),
51
+ retry_times=self._get_gen_cfg("retry_times", 3),
52
+ backoff_factor=self._get_gen_cfg("backoff_factor", 2.0),
53
+ timeout=self._get_gen_cfg("timeout", 30.0),
54
+ max_connections=self._get_gen_cfg("max_connections", 10),
55
+ max_rps=self._get_gen_cfg("max_rps", 1000.0),
56
+ user_agent=self._get_gen_cfg("user_agent", None),
57
+ headers=self._get_gen_cfg("headers", None),
58
+ verify_ssl=self._get_gen_cfg("verify_ssl", True),
59
+ locale_style=self._get_gen_cfg("locale_style", "simplified"),
76
60
  )
77
61
 
78
62
  def get_downloader_config(self) -> DownloaderConfig:
79
63
  """
80
64
  Build a DownloaderConfig using both general and site-specific settings.
81
65
 
82
- Reads from:
83
- - config["general"] for download directories, worker counts, etc.
84
- - config["requests"] for retry and backoff settings
85
- - config["general"]["debug"] for debug toggles (e.g. save_html)
86
- - config["sites"][site] for login credentials and mode
87
-
88
66
  :return: A DownloaderConfig instance with all fields populated.
89
67
  """
90
68
  gen = self._config.get("general", {})
91
- req = self._config.get("requests", {})
92
69
  debug = gen.get("debug", {})
93
- site_cfg = self._get_site_cfg()
94
70
  return DownloaderConfig(
95
- request_interval=gen.get("request_interval", 2.0),
96
- retry_times=req.get("retry_times", 3),
97
- backoff_factor=req.get("backoff_factor", 2.0),
71
+ request_interval=self._get_gen_cfg("request_interval", 2.0),
72
+ retry_times=self._get_gen_cfg("retry_times", 3),
73
+ backoff_factor=self._get_gen_cfg("backoff_factor", 2.0),
74
+ workers=self._get_gen_cfg("workers", 2),
75
+ skip_existing=self._get_gen_cfg("skip_existing", True),
76
+ login_required=self._site_cfg.get("login_required", False),
77
+ save_html=debug.get("save_html", False),
98
78
  raw_data_dir=gen.get("raw_data_dir", "./raw_data"),
99
79
  cache_dir=gen.get("cache_dir", "./novel_cache"),
100
- workers=gen.get("workers", 2),
101
- skip_existing=gen.get("skip_existing", True),
102
- login_required=site_cfg.get("login_required", False),
103
- save_html=debug.get("save_html", False),
104
- mode=site_cfg.get("mode", "session"),
105
80
  storage_batch_size=gen.get("storage_batch_size", 1),
106
- username=site_cfg.get("username", ""),
107
- password=site_cfg.get("password", ""),
108
- cookies=site_cfg.get("cookies", ""),
109
81
  )
110
82
 
111
83
  def get_parser_config(self) -> ParserConfig:
112
84
  """
113
85
  Build a ParserConfig from general, OCR, and site-specific settings.
114
86
 
115
- Reads from:
116
- - config["general"]["cache_dir"] for where to cache intermediate parses
117
- - config["general"]["font_ocr"] for font-decoding and OCR options
118
- - config["sites"][site] for parsing mode and truncation behavior
119
-
120
87
  :return: A ParserConfig instance with all fields populated.
121
88
  """
122
89
  gen = self._config.get("general", {})
123
90
  font_ocr = gen.get("font_ocr", {})
124
- site_cfg = self._get_site_cfg()
125
91
  return ParserConfig(
126
92
  cache_dir=gen.get("cache_dir", "./novel_cache"),
127
- use_truncation=site_cfg.get("use_truncation", True),
93
+ use_truncation=self._site_cfg.get("use_truncation", True),
128
94
  decode_font=font_ocr.get("decode_font", False),
129
- use_freq=font_ocr.get("use_freq", False),
130
- use_ocr=font_ocr.get("use_ocr", True),
131
- use_vec=font_ocr.get("use_vec", False),
132
- ocr_version=font_ocr.get("ocr_version", "v1.0"),
133
95
  save_font_debug=font_ocr.get("save_font_debug", False),
134
96
  batch_size=font_ocr.get("batch_size", 32),
135
- gpu_mem=font_ocr.get("gpu_mem", 500),
136
- gpu_id=font_ocr.get("gpu_id", None),
137
- ocr_weight=font_ocr.get("ocr_weight", 0.6),
138
- vec_weight=font_ocr.get("vec_weight", 0.4),
139
- mode=site_cfg.get("mode", "session"),
140
97
  )
141
98
 
142
99
  def get_exporter_config(self) -> ExporterConfig:
143
100
  """
144
101
  Build an ExporterConfig from output and general settings.
145
102
 
146
- Reads from:
147
- - config["general"] for cache and raw data directories
148
- - config["output"]["formats"] for which formats to generate
149
- - config["output"]["naming"] for filename templates
150
- - config["output"]["epub"] for EPUB-specific options
151
- - config["sites"][site] for export split mode
152
-
153
103
  :return: An ExporterConfig instance with all fields populated.
154
104
  """
155
105
  gen = self._config.get("general", {})
@@ -158,13 +108,12 @@ class ConfigAdapter:
158
108
  fmt = out.get("formats", {})
159
109
  naming = out.get("naming", {})
160
110
  epub_opts = out.get("epub", {})
161
- site_cfg = self._get_site_cfg()
162
111
  cleaner_cfg = self._dict_to_cleaner_cfg(cln)
163
112
  return ExporterConfig(
164
113
  cache_dir=gen.get("cache_dir", "./novel_cache"),
165
114
  raw_data_dir=gen.get("raw_data_dir", "./raw_data"),
166
115
  output_dir=gen.get("output_dir", "./downloads"),
167
- clean_text=out.get("clean_text", True),
116
+ clean_text=cln.get("clean_text", True),
168
117
  make_txt=fmt.get("make_txt", True),
169
118
  make_epub=fmt.get("make_epub", False),
170
119
  make_md=fmt.get("make_md", False),
@@ -172,20 +121,34 @@ class ConfigAdapter:
172
121
  append_timestamp=naming.get("append_timestamp", True),
173
122
  filename_template=naming.get("filename_template", "{title}_{author}"),
174
123
  include_cover=epub_opts.get("include_cover", True),
175
- include_toc=epub_opts.get("include_toc", False),
176
- include_picture=epub_opts.get("include_picture", False),
177
- split_mode=site_cfg.get("split_mode", "book"),
124
+ include_picture=epub_opts.get("include_picture", True),
125
+ split_mode=self._site_cfg.get("split_mode", "book"),
178
126
  cleaner_cfg=cleaner_cfg,
179
127
  )
180
128
 
129
+ def get_login_config(self) -> dict[str, str]:
130
+ """
131
+ Return the subset of login fields present in current site config:
132
+ * `username`
133
+ * `password`
134
+ * `cookies`
135
+ """
136
+ out: dict[str, str] = {}
137
+ for key in ("username", "password", "cookies"):
138
+ val = self._site_cfg.get(key, "")
139
+ val = val.strip()
140
+ if val:
141
+ out[key] = val
142
+ return out
143
+
181
144
  def get_book_ids(self) -> list[BookConfig]:
182
145
  """
183
146
  Extract the list of target books from the site configuration.
184
147
 
185
148
  The site config may specify book_ids as:
186
- - a single string or integer
187
- - a dict with book_id and optional start_id, end_id, ignore_ids
188
- - a list of the above types
149
+ * a single string or integer
150
+ * a dict with book_id and optional start_id, end_id, ignore_ids
151
+ * a list of the above types
189
152
 
190
153
  :return: A list of BookConfig dicts.
191
154
  :raises ValueError: if the raw book_ids is neither a str/int, dict, nor list.
@@ -216,20 +179,14 @@ class ConfigAdapter:
216
179
 
217
180
  return result
218
181
 
219
- def get_log_level(self) -> LogLevel:
182
+ def get_log_level(self) -> str:
220
183
  """
221
184
  Retrieve the logging level from [general.debug].
222
185
 
223
- Reads from config["general"]["debug"]["log_level"], defaulting to "INFO"
224
- if not set or invalid.
225
-
226
- :return: The configured LogLevel literal ("DEBUG", "INFO", "WARNING", "ERROR").
186
+ :return: The configured log level ("DEBUG", "INFO", "WARNING", "ERROR").
227
187
  """
228
188
  debug_cfg = self._config.get("general", {}).get("debug", {})
229
- raw = debug_cfg.get("log_level") or "INFO"
230
- if raw in self._ALLOWED_LOG_LEVELS:
231
- return cast(LogLevel, raw)
232
- return "INFO"
189
+ return debug_cfg.get("log_level") or "INFO"
233
190
 
234
191
  @property
235
192
  def site(self) -> str:
@@ -246,8 +203,12 @@ class ConfigAdapter:
246
203
  :param value: The new site key in config["sites"] to use.
247
204
  """
248
205
  self._site = value
206
+ self._site_cfg = self._get_site_cfg()
207
+
208
+ def _get_gen_cfg(self, key: str, default: T) -> T:
209
+ return self._site_cfg.get(key) or self._gen_cfg.get(key) or default
249
210
 
250
- def _get_site_cfg(self, site: str | None = None) -> dict[str, Any]:
211
+ def _get_site_cfg(self) -> dict[str, Any]:
251
212
  """
252
213
  Retrieve the configuration for a specific site.
253
214
 
@@ -259,13 +220,12 @@ class ConfigAdapter:
259
220
  :param site: Optional override of the site name; defaults to self._site.
260
221
  :return: The site-specific or common configuration dict.
261
222
  """
262
- site = site or self._site
263
- sites_cfg = self._config.get("sites", {}) or {}
223
+ sites_cfg = self._config.get("sites") or {}
264
224
 
265
- if site in sites_cfg:
266
- return sites_cfg[site] or {}
225
+ if self._site in sites_cfg:
226
+ return sites_cfg[self._site] or {}
267
227
 
268
- return sites_cfg.get("common", {}) or {}
228
+ return sites_cfg.get("common") or {}
269
229
 
270
230
  @staticmethod
271
231
  def _dict_to_book_cfg(data: dict[str, Any]) -> BookConfig:
@@ -306,10 +266,10 @@ class ConfigAdapter:
306
266
  title_repl = title_section.get("replace", {})
307
267
 
308
268
  title_ext = title_section.get("external", {})
309
- title_ext_en = title_ext.get("enabled", False)
310
- title_ext_rm_p = title_ext.get("remove_patterns", "")
311
- title_ext_rp_p = title_ext.get("replace", "")
312
- if title_ext_en:
269
+ if title_ext.get("enabled", False):
270
+ title_ext_rm_p = title_ext.get("remove_patterns", "")
271
+ title_ext_rp_p = title_ext.get("replace", "")
272
+
313
273
  title_remove_ext = cls._load_str_list(title_ext_rm_p)
314
274
  title_remove += title_remove_ext
315
275
 
@@ -322,11 +282,11 @@ class ConfigAdapter:
322
282
  content_repl = content_section.get("replace", {})
323
283
 
324
284
  content_ext = content_section.get("external", {})
325
- content_ext_en = content_ext.get("enabled", False)
326
- content_ext_rm_p = content_ext.get("remove_patterns", "")
327
- content_ext_rp_p = content_ext.get("replace", "")
328
285
 
329
- if content_ext_en:
286
+ if content_ext.get("enabled", False):
287
+ content_ext_rm_p = content_ext.get("remove_patterns", "")
288
+ content_ext_rp_p = content_ext.get("replace", "")
289
+
330
290
  content_remove_ext = cls._load_str_list(content_ext_rm_p)
331
291
  content_remove += content_remove_ext
332
292