novel-downloader 1.3.2__py3-none-any.whl → 1.4.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 (213) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/clean.py +97 -78
  3. novel_downloader/cli/config.py +177 -0
  4. novel_downloader/cli/download.py +132 -87
  5. novel_downloader/cli/export.py +77 -0
  6. novel_downloader/cli/main.py +21 -28
  7. novel_downloader/config/__init__.py +1 -25
  8. novel_downloader/config/adapter.py +32 -31
  9. novel_downloader/config/loader.py +3 -3
  10. novel_downloader/config/site_rules.py +1 -2
  11. novel_downloader/core/__init__.py +3 -6
  12. novel_downloader/core/downloaders/__init__.py +10 -13
  13. novel_downloader/core/downloaders/base.py +233 -0
  14. novel_downloader/core/downloaders/biquge.py +27 -0
  15. novel_downloader/core/downloaders/common.py +414 -0
  16. novel_downloader/core/downloaders/esjzone.py +27 -0
  17. novel_downloader/core/downloaders/linovelib.py +27 -0
  18. novel_downloader/core/downloaders/qianbi.py +27 -0
  19. novel_downloader/core/downloaders/qidian.py +352 -0
  20. novel_downloader/core/downloaders/sfacg.py +27 -0
  21. novel_downloader/core/downloaders/yamibo.py +27 -0
  22. novel_downloader/core/exporters/__init__.py +37 -0
  23. novel_downloader/core/{savers → exporters}/base.py +73 -44
  24. novel_downloader/core/exporters/biquge.py +25 -0
  25. novel_downloader/core/exporters/common/__init__.py +12 -0
  26. novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
  29. novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
  31. novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
  35. novel_downloader/core/exporters/esjzone.py +25 -0
  36. novel_downloader/core/exporters/linovelib/__init__.py +10 -0
  37. novel_downloader/core/exporters/linovelib/epub.py +449 -0
  38. novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
  39. novel_downloader/core/exporters/linovelib/txt.py +129 -0
  40. novel_downloader/core/exporters/qianbi.py +25 -0
  41. novel_downloader/core/{savers → exporters}/qidian.py +8 -8
  42. novel_downloader/core/exporters/sfacg.py +25 -0
  43. novel_downloader/core/exporters/yamibo.py +25 -0
  44. novel_downloader/core/factory/__init__.py +5 -17
  45. novel_downloader/core/factory/downloader.py +24 -126
  46. novel_downloader/core/factory/exporter.py +58 -0
  47. novel_downloader/core/factory/fetcher.py +96 -0
  48. novel_downloader/core/factory/parser.py +17 -12
  49. novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
  50. novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
  51. novel_downloader/core/fetchers/base/browser.py +383 -0
  52. novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
  53. novel_downloader/core/fetchers/base/session.py +419 -0
  54. novel_downloader/core/fetchers/biquge/__init__.py +14 -0
  55. novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
  56. novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
  57. novel_downloader/core/fetchers/common/__init__.py +14 -0
  58. novel_downloader/core/fetchers/common/browser.py +79 -0
  59. novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
  60. novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
  61. novel_downloader/core/fetchers/esjzone/browser.py +202 -0
  62. novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
  63. novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
  64. novel_downloader/core/fetchers/linovelib/browser.py +178 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +178 -0
  66. novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
  67. novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
  68. novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
  69. novel_downloader/core/fetchers/qidian/__init__.py +14 -0
  70. novel_downloader/core/fetchers/qidian/browser.py +266 -0
  71. novel_downloader/core/fetchers/qidian/session.py +326 -0
  72. novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
  73. novel_downloader/core/fetchers/sfacg/browser.py +189 -0
  74. novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
  75. novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
  76. novel_downloader/core/fetchers/yamibo/browser.py +229 -0
  77. novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
  78. novel_downloader/core/interfaces/__init__.py +8 -12
  79. novel_downloader/core/interfaces/downloader.py +54 -0
  80. novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
  81. novel_downloader/core/interfaces/fetcher.py +162 -0
  82. novel_downloader/core/interfaces/parser.py +6 -7
  83. novel_downloader/core/parsers/__init__.py +5 -6
  84. novel_downloader/core/parsers/base.py +9 -13
  85. novel_downloader/core/parsers/biquge/main_parser.py +12 -13
  86. novel_downloader/core/parsers/common/helper.py +3 -3
  87. novel_downloader/core/parsers/common/main_parser.py +39 -34
  88. novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
  89. novel_downloader/core/parsers/linovelib/__init__.py +10 -0
  90. novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
  91. novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
  92. novel_downloader/core/parsers/qidian/__init__.py +2 -11
  93. novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
  94. novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
  95. novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
  96. novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
  97. novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
  98. novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
  99. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
  100. novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
  101. novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
  102. novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
  103. novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
  104. novel_downloader/locales/en.json +18 -2
  105. novel_downloader/locales/zh.json +18 -2
  106. novel_downloader/models/__init__.py +64 -0
  107. novel_downloader/models/browser.py +21 -0
  108. novel_downloader/models/chapter.py +25 -0
  109. novel_downloader/models/config.py +100 -0
  110. novel_downloader/models/login.py +20 -0
  111. novel_downloader/models/site_rules.py +99 -0
  112. novel_downloader/models/tasks.py +33 -0
  113. novel_downloader/models/types.py +15 -0
  114. novel_downloader/resources/config/settings.toml +31 -25
  115. novel_downloader/resources/json/linovelib_font_map.json +3573 -0
  116. novel_downloader/tui/__init__.py +7 -0
  117. novel_downloader/tui/app.py +32 -0
  118. novel_downloader/tui/main.py +17 -0
  119. novel_downloader/tui/screens/__init__.py +14 -0
  120. novel_downloader/tui/screens/home.py +191 -0
  121. novel_downloader/tui/screens/login.py +74 -0
  122. novel_downloader/tui/styles/home_layout.tcss +79 -0
  123. novel_downloader/tui/widgets/richlog_handler.py +24 -0
  124. novel_downloader/utils/__init__.py +6 -0
  125. novel_downloader/utils/chapter_storage.py +25 -38
  126. novel_downloader/utils/constants.py +15 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/file_utils/io.py +1 -1
  130. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  131. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  132. novel_downloader/utils/hash_store.py +10 -18
  133. novel_downloader/utils/hash_utils.py +3 -2
  134. novel_downloader/utils/logger.py +2 -3
  135. novel_downloader/utils/network.py +53 -39
  136. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  137. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  138. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  139. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  140. novel_downloader/utils/time_utils/sleep_utils.py +3 -3
  141. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
  142. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  143. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  144. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  145. novel_downloader/cli/interactive.py +0 -66
  146. novel_downloader/cli/settings.py +0 -177
  147. novel_downloader/config/models.py +0 -187
  148. novel_downloader/core/downloaders/base/__init__.py +0 -14
  149. novel_downloader/core/downloaders/base/base_async.py +0 -153
  150. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  151. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  152. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  153. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  154. novel_downloader/core/downloaders/common/__init__.py +0 -14
  155. novel_downloader/core/downloaders/common/common_async.py +0 -218
  156. novel_downloader/core/downloaders/common/common_sync.py +0 -210
  157. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  158. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  159. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  160. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  161. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  162. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  163. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  164. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
  165. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  166. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  167. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  168. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  169. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  170. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  171. novel_downloader/core/factory/requester.py +0 -144
  172. novel_downloader/core/factory/saver.py +0 -56
  173. novel_downloader/core/interfaces/async_downloader.py +0 -36
  174. novel_downloader/core/interfaces/async_requester.py +0 -84
  175. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  176. novel_downloader/core/interfaces/sync_requester.py +0 -82
  177. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  178. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  179. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  180. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  181. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  182. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  183. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  184. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  185. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  186. novel_downloader/core/requesters/base/async_session.py +0 -410
  187. novel_downloader/core/requesters/base/browser.py +0 -337
  188. novel_downloader/core/requesters/base/session.py +0 -378
  189. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  190. novel_downloader/core/requesters/common/__init__.py +0 -17
  191. novel_downloader/core/requesters/common/session.py +0 -113
  192. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  193. novel_downloader/core/requesters/esjzone/session.py +0 -235
  194. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  195. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  196. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  197. novel_downloader/core/requesters/qidian/session.py +0 -290
  198. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  199. novel_downloader/core/requesters/sfacg/session.py +0 -242
  200. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  201. novel_downloader/core/requesters/yamibo/session.py +0 -237
  202. novel_downloader/core/savers/__init__.py +0 -34
  203. novel_downloader/core/savers/biquge.py +0 -25
  204. novel_downloader/core/savers/common/__init__.py +0 -12
  205. novel_downloader/core/savers/epub_utils/__init__.py +0 -26
  206. novel_downloader/core/savers/esjzone.py +0 -25
  207. novel_downloader/core/savers/qianbi.py +0 -25
  208. novel_downloader/core/savers/sfacg.py +0 -25
  209. novel_downloader/core/savers/yamibo.py +0 -25
  210. novel_downloader/resources/config/rules.toml +0 -196
  211. novel_downloader-1.3.2.dist-info/RECORD +0 -165
  212. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  213. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui
4
+ --------------------
5
+
6
+ This module exposes the TUI entry point.
7
+ """
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.app
4
+ ------------------------
5
+
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from textual.app import App, ComposeResult
11
+ from textual.containers import Container
12
+ from textual.widgets import Footer, Header
13
+
14
+ from novel_downloader.config import load_config
15
+ from novel_downloader.tui.screens import HomeScreen
16
+
17
+
18
+ class NovelDownloaderTUI(App): # type: ignore[misc]
19
+ TITLE = "Novel Downloader TUI"
20
+ SCREENS = {
21
+ "home": HomeScreen,
22
+ }
23
+ config: dict[str, Any]
24
+
25
+ def compose(self) -> ComposeResult:
26
+ yield Header()
27
+ yield Container(id="main_area")
28
+ yield Footer()
29
+
30
+ def on_mount(self) -> None:
31
+ self.config = load_config()
32
+ self.push_screen("home")
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.main
4
+ -------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.tui.app import NovelDownloaderTUI
9
+
10
+
11
+ def tui_main() -> None:
12
+ app = NovelDownloaderTUI()
13
+ app.run()
14
+
15
+
16
+ if __name__ == "__main__":
17
+ tui_main()
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.screens
4
+ ----------------------------
5
+
6
+ """
7
+
8
+ from .home import HomeScreen
9
+ from .login import LoginScreen
10
+
11
+ __all__ = [
12
+ "HomeScreen",
13
+ "LoginScreen",
14
+ ]
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.screens.home
4
+ ---------------------------------
5
+
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Any
11
+
12
+ from textual.app import ComposeResult
13
+ from textual.containers import Horizontal, Vertical
14
+ from textual.screen import Screen
15
+ from textual.widgets import Button, Input, ProgressBar, RichLog, Select, Static
16
+
17
+ from novel_downloader.config import ConfigAdapter
18
+ from novel_downloader.core.factory import (
19
+ get_downloader,
20
+ get_exporter,
21
+ get_fetcher,
22
+ get_parser,
23
+ )
24
+ from novel_downloader.core.interfaces import FetcherProtocol
25
+ from novel_downloader.models import LoginField
26
+ from novel_downloader.tui.widgets.richlog_handler import RichLogHandler
27
+ from novel_downloader.utils.i18n import t
28
+
29
+
30
+ class HomeScreen(Screen): # type: ignore[misc]
31
+ CSS_PATH = "../styles/home_layout.tcss"
32
+
33
+ def compose(self) -> ComposeResult:
34
+ yield Vertical(
35
+ self._make_title_bar(),
36
+ self._make_input_row(),
37
+ ProgressBar(id="prog", name="下载进度"),
38
+ Static("下载进度: 0/0 章", id="label-progress"),
39
+ RichLog(id="log", highlight=True, markup=False),
40
+ id="main-layout",
41
+ )
42
+
43
+ def on_mount(self) -> None:
44
+ log_widget = self.query_one("#log", RichLog)
45
+
46
+ self._log_handler = RichLogHandler(log_widget)
47
+ self._log_handler.setLevel(logging.INFO)
48
+ self._log_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
49
+
50
+ self._setup_logging(self._log_handler)
51
+
52
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
53
+ if event.button.id == "exit":
54
+ logging.info("退出应用")
55
+ self.app.exit()
56
+
57
+ elif event.button.id == "settings":
58
+ logging.info("设置功能暂未实现")
59
+
60
+ elif event.button.id == "download":
61
+ site = self.query_one("#site", Select).value
62
+ ids = self.query_one("#book_ids", Input).value
63
+ if not site or not ids.strip():
64
+ logging.warning("请填写完整信息")
65
+ return
66
+ id_list = {x.strip() for x in ids.split(",") if x.strip()}
67
+ adapter = ConfigAdapter(config=self.app.config, site=str(site))
68
+ asyncio.create_task(self._download(adapter, str(site), id_list))
69
+
70
+ def _make_title_bar(self) -> Horizontal:
71
+ return Horizontal(
72
+ Static("小说下载器", id="title"),
73
+ Button("设置", id="settings"),
74
+ Button("关闭", id="exit"),
75
+ id="title-bar",
76
+ )
77
+
78
+ def _make_input_row(self) -> Horizontal:
79
+ return Horizontal(
80
+ Vertical(self._make_site_select(), classes="left"),
81
+ Vertical(
82
+ Input(placeholder="输入书籍ID (支持逗号分隔)", id="book_ids"),
83
+ classes="middle",
84
+ ),
85
+ Vertical(Button("下载", id="download"), classes="right"),
86
+ id="input-row",
87
+ )
88
+
89
+ def _make_site_select(self) -> Select:
90
+ return Select(
91
+ options=[
92
+ ("起点中文网", "qidian"),
93
+ ("笔趣阁", "biquge"),
94
+ ("铅笔小说", "qianbi"),
95
+ ("SF轻小说", "sfacg"),
96
+ ("ESJ Zone", "esjzone"),
97
+ ("百合会", "yamibo"),
98
+ ("哔哩轻小说", "linovelib"),
99
+ ],
100
+ prompt="选择站点",
101
+ value="qidian",
102
+ id="site",
103
+ )
104
+
105
+ async def _download(
106
+ self,
107
+ adapter: ConfigAdapter,
108
+ site: str,
109
+ valid_book_ids: set[str],
110
+ ) -> None:
111
+ btn = self.query_one("#download", Button)
112
+ btn.disabled = True
113
+ try:
114
+ logging.info(f"下载请求: {site} | {valid_book_ids}")
115
+ downloader_cfg = adapter.get_downloader_config()
116
+ fetcher_cfg = adapter.get_fetcher_config()
117
+ parser_cfg = adapter.get_parser_config()
118
+ exporter_cfg = adapter.get_exporter_config()
119
+
120
+ parser = get_parser(site, parser_cfg)
121
+ exporter = get_exporter(site, exporter_cfg)
122
+ self._setup_logging(self._log_handler)
123
+
124
+ async with get_fetcher(site, fetcher_cfg) as fetcher:
125
+ if downloader_cfg.login_required and not await fetcher.load_state():
126
+ login_data = await self._prompt_login_fields(
127
+ fetcher, fetcher.login_fields, downloader_cfg
128
+ )
129
+ if not await fetcher.login(**login_data):
130
+ logging.info(t("download_login_failed"))
131
+ return
132
+ await fetcher.save_state()
133
+
134
+ downloader = get_downloader(
135
+ fetcher=fetcher,
136
+ parser=parser,
137
+ exporter=exporter,
138
+ site=site,
139
+ config=downloader_cfg,
140
+ )
141
+
142
+ for book_id in valid_book_ids:
143
+ logging.info(t("download_downloading", book_id=book_id, site=site))
144
+ await downloader.download(
145
+ book_id, progress_hook=self._update_progress
146
+ )
147
+
148
+ if downloader_cfg.login_required and fetcher.is_logged_in:
149
+ await fetcher.save_state()
150
+ finally:
151
+ btn.disabled = False
152
+
153
+ async def _prompt_login_fields(
154
+ self,
155
+ fetcher: FetcherProtocol,
156
+ fields: list[LoginField],
157
+ cfg: Any = None,
158
+ ) -> dict[str, Any]:
159
+ """
160
+ Push a LoginScreen to collect all required fields,
161
+ then return the dict of values when the user submits.
162
+ """
163
+ # cfg_dict = asdict(cfg) if cfg else {}
164
+ # login_screen = LoginScreen(fields, cfg_dict)
165
+ # await self.app.push_screen(login_screen)
166
+ # await self.app.pop_screen()
167
+ return {}
168
+
169
+ def _setup_logging(self, handler: logging.Handler) -> None:
170
+ """
171
+ Attach the given handler to the root logger.
172
+ """
173
+ ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
174
+ ft_logger.setLevel(logging.ERROR)
175
+ ft_logger.propagate = False
176
+
177
+ logger = logging.getLogger()
178
+ logger.setLevel(logging.INFO)
179
+
180
+ logger.handlers = [
181
+ h for h in logger.handlers if not isinstance(h, RichLogHandler)
182
+ ]
183
+ logger.addHandler(handler)
184
+
185
+ async def _update_progress(self, done: int, total: int) -> None:
186
+ prog = self.query_one("#prog", ProgressBar)
187
+ label = self.query_one("#label-progress", Static)
188
+
189
+ prog.update(total=total, progress=min(done, total))
190
+
191
+ label.update(f"下载进度: {done}/{total} 章")
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.screens.login
4
+ ---------------------------------
5
+
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Vertical
12
+ from textual.screen import Screen
13
+ from textual.widgets import Button, Input, Static
14
+
15
+ from novel_downloader.models import LoginField
16
+
17
+
18
+ class LoginScreen(Screen): # type: ignore[misc]
19
+ """
20
+ A modal screen that gathers login fields, then fires LoginScreen.Submitted.
21
+ """
22
+
23
+ BINDINGS = [("escape", "app.pop_screen", "取消")]
24
+
25
+ def __init__(
26
+ self,
27
+ fields: list[LoginField],
28
+ cfg: dict[str, Any] | None = None,
29
+ ) -> None:
30
+ super().__init__()
31
+ self.fields = fields
32
+ self.cfg = cfg or {}
33
+
34
+ def compose(self) -> ComposeResult:
35
+ widgets = []
36
+ for field in self.fields:
37
+ # show label and optional description
38
+ widgets.append(Static(field.label))
39
+ if field.description:
40
+ widgets.append(Static(f"[i]{field.description}[/]"))
41
+
42
+ # pick input type
43
+ if field.type == "password":
44
+ inp = Input(
45
+ placeholder=field.placeholder or "",
46
+ password=True,
47
+ id=field.name,
48
+ )
49
+ else:
50
+ inp = Input(
51
+ placeholder=field.placeholder or "",
52
+ id=field.name,
53
+ )
54
+
55
+ # pre-fill from config if present
56
+ existing = self.cfg.get(field.name, "").strip()
57
+ if existing:
58
+ inp.value = existing
59
+
60
+ widgets.append(inp)
61
+
62
+ # submit button at the end
63
+ widgets.append(Button("提交", id="submit"))
64
+ yield Vertical(*widgets, id="login-form")
65
+
66
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
67
+ if event.button.id == "submit":
68
+ data: dict[str, Any] = {}
69
+ for field in self.fields:
70
+ inp = self.query_one(f"#{field.name}", Input)
71
+ value = inp.value
72
+ if not value and self.cfg.get(field.name):
73
+ value = self.cfg[field.name]
74
+ data[field.name] = value
@@ -0,0 +1,79 @@
1
+ #main-layout {
2
+ grid-rows: 3 auto 1 auto 1fr;
3
+ grid-columns: 1fr;
4
+ grid-gutter: 1;
5
+ padding: 1;
6
+ height: 100%;
7
+ }
8
+
9
+ #title-bar {
10
+ height: 3;
11
+ layout: horizontal;
12
+ align: left middle;
13
+ padding: 0 1;
14
+ background: $boost;
15
+ }
16
+
17
+ #title {
18
+ width: 1fr;
19
+ content-align: left middle;
20
+ }
21
+
22
+ #settings,
23
+ #exit {
24
+ width: 8;
25
+ padding: 0 1;
26
+ }
27
+
28
+ #input-row {
29
+ layout: horizontal;
30
+ padding: 1 0;
31
+ overflow-x: auto;
32
+ }
33
+
34
+ #site {
35
+ width: 20;
36
+ margin-right: 1;
37
+ }
38
+
39
+ #book_ids {
40
+ width: 1fr;
41
+ min-width: 0;
42
+ margin-right: 1;
43
+ }
44
+
45
+ #download {
46
+ width: 15;
47
+ }
48
+
49
+ #site,
50
+ #book_ids,
51
+ #download {
52
+ width: 100%;
53
+ }
54
+
55
+ Button#download {
56
+ border: round $accent;
57
+ padding: 0 1;
58
+ }
59
+ Button#download:hover {
60
+ background: $accent-lighten-3;
61
+ color: $text;
62
+ }
63
+
64
+
65
+ #prog {
66
+ height: 1;
67
+ color: $success;
68
+ }
69
+
70
+ #label {
71
+ content-align: left middle;
72
+ padding-left: 1;
73
+ }
74
+
75
+ #log {
76
+ border: round $primary;
77
+ padding: 1;
78
+ overflow-y: auto;
79
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.tui.widgets.richlog_handler
4
+ --------------------------------------------
5
+
6
+ """
7
+
8
+ import logging
9
+ from logging import LogRecord
10
+
11
+ from textual.widgets import RichLog
12
+
13
+
14
+ class RichLogHandler(logging.Handler):
15
+ def __init__(self, rich_log_widget: RichLog):
16
+ super().__init__()
17
+ self.rich_log_widget = rich_log_widget
18
+
19
+ def emit(self, record: LogRecord) -> None:
20
+ msg = self.format(record)
21
+ try:
22
+ self.rich_log_widget.write(msg)
23
+ except Exception:
24
+ self.handleError(record)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils
4
+ ----------------------
5
+
6
+ """
@@ -12,7 +12,13 @@ import json
12
12
  import sqlite3
13
13
  import types
14
14
  from pathlib import Path
15
- from typing import Any, Literal, Self, TypedDict, cast
15
+ from typing import Any, Self, cast
16
+
17
+ from novel_downloader.models import (
18
+ ChapterDict,
19
+ SaveMode,
20
+ StorageBackend,
21
+ )
16
22
 
17
23
  from .file_utils import save_as_json
18
24
 
@@ -26,27 +32,6 @@ CREATE TABLE IF NOT EXISTS "{table}" (
26
32
  """
27
33
 
28
34
 
29
- class ChapterDict(TypedDict, total=True):
30
- """
31
- TypedDict for a novel chapter.
32
-
33
- Fields:
34
- id -- Unique chapter identifier
35
- title -- Chapter title
36
- content -- Chapter text
37
- extra -- Arbitrary metadata (e.g. author remarks, timestamps)
38
- """
39
-
40
- id: str
41
- title: str
42
- content: str
43
- extra: dict[str, Any]
44
-
45
-
46
- BackendType = Literal["json", "sqlite"]
47
- SaveMode = Literal["overwrite", "skip"]
48
-
49
-
50
35
  class ChapterStorage:
51
36
  """
52
37
  Manage storage of chapters in JSON files or an SQLite database.
@@ -60,7 +45,7 @@ class ChapterStorage:
60
45
  self,
61
46
  raw_base: str | Path,
62
47
  namespace: str,
63
- backend_type: BackendType = "json",
48
+ backend_type: StorageBackend = "json",
64
49
  *,
65
50
  batch_size: int = 1,
66
51
  ) -> None:
@@ -70,6 +55,7 @@ class ChapterStorage:
70
55
  self._batch_size = batch_size
71
56
  self._pending = 0
72
57
  self._conn: sqlite3.Connection | None = None
58
+ self._existing_ids: set[str] = set()
73
59
 
74
60
  if self.backend == "json":
75
61
  self._init_json()
@@ -80,6 +66,7 @@ class ChapterStorage:
80
66
  """Prepare directory for JSON files."""
81
67
  self._json_dir = self.raw_base / self.namespace
82
68
  self._json_dir.mkdir(parents=True, exist_ok=True)
69
+ self._existing_ids = {p.stem for p in self._json_dir.glob("*.json")}
83
70
 
84
71
  def _init_sql(self) -> None:
85
72
  """Prepare SQLite connection and ensure table exists."""
@@ -89,21 +76,13 @@ class ChapterStorage:
89
76
  self._conn.execute(stmt)
90
77
  self._conn.commit()
91
78
 
79
+ cur = self._conn.execute(f'SELECT id FROM "{self.namespace}"')
80
+ self._existing_ids = {row[0] for row in cur.fetchall()}
81
+
92
82
  def _json_path(self, chap_id: str) -> Path:
93
83
  """Return Path for JSON file of given chapter ID."""
94
84
  return self._json_dir / f"{chap_id}.json"
95
85
 
96
- def _exists_json(self, chap_id: str) -> bool:
97
- return self._json_path(chap_id).is_file()
98
-
99
- def _exists_sql(self, chap_id: str) -> bool:
100
- if self._conn is None:
101
- raise RuntimeError("ChapterStorage is closed")
102
- cur = self._conn.execute(
103
- f'SELECT 1 FROM "{self.namespace}" WHERE id = ? LIMIT 1', (chap_id,)
104
- )
105
- return cur.fetchone() is not None
106
-
107
86
  def exists(self, chap_id: str) -> bool:
108
87
  """
109
88
  Check if a chapter exists.
@@ -111,10 +90,7 @@ class ChapterStorage:
111
90
  :param chap_id: Chapter identifier.
112
91
  :return: True if found, else False.
113
92
  """
114
- if self.backend == "json":
115
- return self._exists_json(chap_id)
116
- else:
117
- return self._exists_sql(chap_id)
93
+ return chap_id in self._existing_ids
118
94
 
119
95
  def _load_json(self, chap_id: str) -> ChapterDict:
120
96
  raw = self._json_path(chap_id).read_text(encoding="utf-8")
@@ -153,6 +129,7 @@ class ChapterStorage:
153
129
  def _save_json(self, data: ChapterDict, on_exist: SaveMode) -> None:
154
130
  path = self._json_path(data["id"])
155
131
  save_as_json(data, path, on_exist=on_exist)
132
+ self._existing_ids.add(data["id"])
156
133
 
157
134
  def _save_sql(self, data: ChapterDict, on_exist: SaveMode) -> None:
158
135
  if self._conn is None:
@@ -173,6 +150,7 @@ class ChapterStorage:
173
150
  json.dumps(data["extra"], ensure_ascii=False),
174
151
  ),
175
152
  )
153
+ self._existing_ids.add(data["id"])
176
154
  if self._batch_size == 1:
177
155
  self._conn.commit()
178
156
  else:
@@ -218,6 +196,8 @@ class ChapterStorage:
218
196
  with self._conn:
219
197
  self._conn.executemany(sql, params)
220
198
 
199
+ self._existing_ids.update(data["id"] for data in datas)
200
+
221
201
  def save(
222
202
  self,
223
203
  data: ChapterDict,
@@ -338,3 +318,10 @@ class ChapterStorage:
338
318
 
339
319
  def __del__(self) -> None:
340
320
  self.close()
321
+
322
+ def __repr__(self) -> str:
323
+ return (
324
+ f"<ChapterStorage ns='{self.namespace}' "
325
+ f"backend='{self.backend}' "
326
+ f"path='{self.raw_base}'>"
327
+ )
@@ -9,7 +9,7 @@ Constants and default paths used throughout the NovelDownloader project.
9
9
  from importlib.resources import files
10
10
  from pathlib import Path
11
11
 
12
- from platformdirs import user_config_dir
12
+ from platformdirs import user_config_path
13
13
 
14
14
  # -----------------------------------------------------------------------------
15
15
  # Application identity
@@ -20,15 +20,20 @@ APP_DIR_NAME = "novel_downloader" # Directory name for platformdirs
20
20
  LOGGER_NAME = PACKAGE_NAME # Root logger name
21
21
 
22
22
  SUPPORTED_SITES = {
23
- "qidian",
24
23
  "biquge",
24
+ "esjzone",
25
+ "linovelib",
26
+ "qianbi",
27
+ "qidian",
28
+ "sfacg",
29
+ "yamibo",
25
30
  }
26
31
 
27
32
  # -----------------------------------------------------------------------------
28
33
  # Base directories
29
34
  # -----------------------------------------------------------------------------
30
35
  # Base config directory (e.g. ~/AppData/Local/novel_downloader/)
31
- BASE_CONFIG_DIR = Path(user_config_dir(APP_DIR_NAME, appauthor=False))
36
+ BASE_CONFIG_DIR = Path(user_config_path(APP_DIR_NAME, appauthor=False))
32
37
  WORK_DIR = Path.cwd()
33
38
  PACKAGE_ROOT: Path = Path(__file__).parent.parent
34
39
  LOCALES_DIR: Path = PACKAGE_ROOT / "locales"
@@ -79,11 +84,9 @@ DEFAULT_USER_HEADERS = {
79
84
  # Embedded resources (via importlib.resources)
80
85
  # -----------------------------------------------------------------------------
81
86
  BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.toml")
82
- BASE_RULE_PATH = files("novel_downloader.resources.config").joinpath("rules.toml")
83
87
 
84
88
  DEFAULT_SETTINGS_PATHS = [
85
89
  BASE_CONFIG_PATH,
86
- BASE_RULE_PATH,
87
90
  ]
88
91
 
89
92
  # CSS Styles
@@ -101,6 +104,9 @@ VOLUME_BORDER_IMAGE_PATH = files("novel_downloader.resources.images").joinpath(
101
104
  REPLACE_WORD_MAP_PATH = files("novel_downloader.resources.json").joinpath(
102
105
  "replace_word_map.json"
103
106
  )
107
+ LINOVELIB_FONT_MAP_PATH = files("novel_downloader.resources.json").joinpath(
108
+ "linovelib_font_map.json"
109
+ )
104
110
 
105
111
  # JavaScript
106
112
  QD_DECRYPT_SCRIPT_PATH = files("novel_downloader.resources.js_scripts").joinpath(
@@ -116,6 +122,10 @@ BLACKLIST_PATH = files("novel_downloader.resources.text").joinpath("blacklist.tx
116
122
  EPUB_IMAGE_FOLDER = "Images"
117
123
  EPUB_TEXT_FOLDER = "Text"
118
124
 
125
+ EPUB_IMAGE_WRAPPER = (
126
+ '<div class="duokan-image-single illus"><img src="../Images/{filename}" /></div>'
127
+ )
128
+
119
129
  EPUB_OPTIONS = {
120
130
  # guide 是 EPUB 2 的一个部分, 包含封面, 目录, 索引等重要导航信息
121
131
  "epub2_guide": True,