novel-downloader 1.2.2__py3-none-any.whl → 1.3.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 (128) hide show
  1. novel_downloader/__init__.py +1 -2
  2. novel_downloader/cli/__init__.py +0 -1
  3. novel_downloader/cli/clean.py +2 -10
  4. novel_downloader/cli/download.py +16 -22
  5. novel_downloader/cli/interactive.py +0 -1
  6. novel_downloader/cli/main.py +1 -3
  7. novel_downloader/cli/settings.py +8 -8
  8. novel_downloader/config/__init__.py +0 -1
  9. novel_downloader/config/adapter.py +32 -27
  10. novel_downloader/config/loader.py +116 -108
  11. novel_downloader/config/models.py +35 -29
  12. novel_downloader/config/site_rules.py +2 -4
  13. novel_downloader/core/__init__.py +0 -1
  14. novel_downloader/core/downloaders/__init__.py +4 -4
  15. novel_downloader/core/downloaders/base/__init__.py +14 -0
  16. novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
  17. novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
  18. novel_downloader/core/downloaders/biquge/__init__.py +12 -0
  19. novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
  20. novel_downloader/core/downloaders/common/__init__.py +14 -0
  21. novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
  22. novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +33 -21
  23. novel_downloader/core/downloaders/qidian/__init__.py +10 -0
  24. novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +79 -62
  25. novel_downloader/core/factory/__init__.py +4 -5
  26. novel_downloader/core/factory/{downloader_factory.py → downloader.py} +25 -26
  27. novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
  28. novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
  29. novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
  30. novel_downloader/core/interfaces/__init__.py +8 -9
  31. novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
  32. novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +23 -12
  33. novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
  34. novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
  35. novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
  36. novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +31 -17
  37. novel_downloader/core/parsers/__init__.py +5 -4
  38. novel_downloader/core/parsers/{base_parser.py → base.py} +18 -9
  39. novel_downloader/core/parsers/biquge/__init__.py +10 -0
  40. novel_downloader/core/parsers/biquge/main_parser.py +126 -0
  41. novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
  42. novel_downloader/core/parsers/{common_parser → common}/helper.py +13 -13
  43. novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
  44. novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
  45. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
  46. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +40 -48
  47. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
  48. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
  49. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +14 -10
  50. novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
  51. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +36 -44
  52. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
  53. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
  54. novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +14 -10
  55. novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
  56. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
  57. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/book_info_parser.py +5 -6
  58. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +7 -8
  59. novel_downloader/core/requesters/__init__.py +9 -5
  60. novel_downloader/core/requesters/base/__init__.py +16 -0
  61. novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +177 -73
  62. novel_downloader/core/requesters/base/browser.py +340 -0
  63. novel_downloader/core/requesters/base/session.py +364 -0
  64. novel_downloader/core/requesters/biquge/__init__.py +12 -0
  65. novel_downloader/core/requesters/biquge/session.py +90 -0
  66. novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
  67. novel_downloader/core/requesters/common/async_session.py +96 -0
  68. novel_downloader/core/requesters/common/session.py +113 -0
  69. novel_downloader/core/requesters/qidian/__init__.py +21 -0
  70. novel_downloader/core/requesters/qidian/broswer.py +306 -0
  71. novel_downloader/core/requesters/qidian/session.py +287 -0
  72. novel_downloader/core/savers/__init__.py +5 -3
  73. novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
  74. novel_downloader/core/savers/biquge.py +25 -0
  75. novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
  76. novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +23 -51
  77. novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
  78. novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
  79. novel_downloader/core/savers/epub_utils/__init__.py +0 -1
  80. novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
  81. novel_downloader/core/savers/epub_utils/initializer.py +4 -5
  82. novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
  83. novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
  84. novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
  85. novel_downloader/locales/en.json +8 -4
  86. novel_downloader/locales/zh.json +5 -1
  87. novel_downloader/resources/config/settings.toml +88 -0
  88. novel_downloader/utils/cache.py +2 -2
  89. novel_downloader/utils/chapter_storage.py +340 -0
  90. novel_downloader/utils/constants.py +6 -4
  91. novel_downloader/utils/crypto_utils.py +3 -3
  92. novel_downloader/utils/file_utils/__init__.py +0 -1
  93. novel_downloader/utils/file_utils/io.py +12 -17
  94. novel_downloader/utils/file_utils/normalize.py +1 -3
  95. novel_downloader/utils/file_utils/sanitize.py +2 -9
  96. novel_downloader/utils/fontocr/__init__.py +0 -1
  97. novel_downloader/utils/fontocr/ocr_v1.py +19 -22
  98. novel_downloader/utils/fontocr/ocr_v2.py +147 -60
  99. novel_downloader/utils/hash_store.py +19 -20
  100. novel_downloader/utils/hash_utils.py +0 -1
  101. novel_downloader/utils/i18n.py +3 -4
  102. novel_downloader/utils/logger.py +5 -6
  103. novel_downloader/utils/model_loader.py +5 -8
  104. novel_downloader/utils/network.py +9 -10
  105. novel_downloader/utils/state.py +6 -7
  106. novel_downloader/utils/text_utils/__init__.py +0 -1
  107. novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
  108. novel_downloader/utils/text_utils/diff_display.py +0 -1
  109. novel_downloader/utils/text_utils/font_mapping.py +1 -4
  110. novel_downloader/utils/text_utils/text_cleaning.py +0 -1
  111. novel_downloader/utils/time_utils/__init__.py +0 -1
  112. novel_downloader/utils/time_utils/datetime_utils.py +8 -10
  113. novel_downloader/utils/time_utils/sleep_utils.py +1 -3
  114. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/METADATA +14 -17
  115. novel_downloader-1.3.0.dist-info/RECORD +127 -0
  116. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/WHEEL +1 -1
  117. novel_downloader/core/requesters/base_browser.py +0 -214
  118. novel_downloader/core/requesters/base_session.py +0 -246
  119. novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
  120. novel_downloader/core/requesters/common_requester/common_session.py +0 -126
  121. novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
  122. novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -396
  123. novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
  124. novel_downloader/resources/config/settings.yaml +0 -76
  125. novel_downloader-1.2.2.dist-info/RECORD +0 -115
  126. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/entry_points.txt +0 -0
  127. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/licenses/LICENSE +0 -0
  128. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.chapter_storage
4
+ --------------------------------------
5
+
6
+ Storage module for managing novel chapters in
7
+ either JSON file form or an SQLite database.
8
+ """
9
+
10
+ import contextlib
11
+ import json
12
+ import sqlite3
13
+ import types
14
+ from pathlib import Path
15
+ from typing import Any, Literal, Self, TypedDict, cast
16
+
17
+ from .file_utils import save_as_json
18
+
19
+ _CREATE_TABLE_SQL = """
20
+ CREATE TABLE IF NOT EXISTS "{table}" (
21
+ id TEXT PRIMARY KEY,
22
+ title TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ extra TEXT NOT NULL
25
+ )
26
+ """
27
+
28
+
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
+ class ChapterStorage:
51
+ """
52
+ Manage storage of chapters in JSON files or an SQLite database.
53
+
54
+ :param raw_base: Base directory or file path for storage.
55
+ :param namespace: Novel identifier (subfolder name or DB/table basename).
56
+ :param backend_type: "json" (default) or "sqlite".
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ raw_base: str | Path,
62
+ namespace: str,
63
+ backend_type: BackendType = "json",
64
+ *,
65
+ batch_size: int = 1,
66
+ ) -> None:
67
+ self.raw_base = Path(raw_base)
68
+ self.namespace = namespace
69
+ self.backend = backend_type
70
+ self._batch_size = batch_size
71
+ self._pending = 0
72
+ self._conn: sqlite3.Connection | None = None
73
+
74
+ if self.backend == "json":
75
+ self._init_json()
76
+ else:
77
+ self._init_sql()
78
+
79
+ def _init_json(self) -> None:
80
+ """Prepare directory for JSON files."""
81
+ self._json_dir = self.raw_base / self.namespace
82
+ self._json_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ def _init_sql(self) -> None:
85
+ """Prepare SQLite connection and ensure table exists."""
86
+ self._db_path = self.raw_base / f"{self.namespace}.sqlite"
87
+ self._conn = sqlite3.connect(self._db_path)
88
+ stmt = _CREATE_TABLE_SQL.format(table=self.namespace)
89
+ self._conn.execute(stmt)
90
+ self._conn.commit()
91
+
92
+ def _json_path(self, chap_id: str) -> Path:
93
+ """Return Path for JSON file of given chapter ID."""
94
+ return self._json_dir / f"{chap_id}.json"
95
+
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
+ def exists(self, chap_id: str) -> bool:
108
+ """
109
+ Check if a chapter exists.
110
+
111
+ :param chap_id: Chapter identifier.
112
+ :return: True if found, else False.
113
+ """
114
+ if self.backend == "json":
115
+ return self._exists_json(chap_id)
116
+ else:
117
+ return self._exists_sql(chap_id)
118
+
119
+ def _load_json(self, chap_id: str) -> ChapterDict:
120
+ raw = self._json_path(chap_id).read_text(encoding="utf-8")
121
+ return cast(ChapterDict, json.loads(raw))
122
+
123
+ def _load_sql(self, chap_id: str) -> ChapterDict:
124
+ if self._conn is None:
125
+ raise RuntimeError("ChapterStorage is closed")
126
+ cur = self._conn.execute(
127
+ f'SELECT id, title, content, extra FROM "{self.namespace}" WHERE id = ?',
128
+ (chap_id,),
129
+ )
130
+ row = cur.fetchone()
131
+ return {
132
+ "id": row[0],
133
+ "title": row[1],
134
+ "content": row[2],
135
+ "extra": json.loads(row[3]),
136
+ }
137
+
138
+ def get(self, chap_id: str) -> ChapterDict | dict[str, Any]:
139
+ """
140
+ Retrieve chapter by ID.
141
+
142
+ :param chap_id: Chapter identifier.
143
+ :return: ChapterDict if exists, else empty dict.
144
+ """
145
+ if not self.exists(chap_id):
146
+ return {}
147
+ return (
148
+ self._load_json(chap_id)
149
+ if self.backend == "json"
150
+ else self._load_sql(chap_id)
151
+ )
152
+
153
+ def _save_json(self, data: ChapterDict, on_exist: SaveMode) -> None:
154
+ path = self._json_path(data["id"])
155
+ save_as_json(data, path, on_exist=on_exist)
156
+
157
+ def _save_sql(self, data: ChapterDict, on_exist: SaveMode) -> None:
158
+ if self._conn is None:
159
+ raise RuntimeError("ChapterStorage is closed")
160
+ sql = (
161
+ f'INSERT OR REPLACE INTO "{self.namespace}" '
162
+ "(id, title, content, extra) VALUES (?, ?, ?, ?)"
163
+ if on_exist == "overwrite"
164
+ else f'INSERT OR IGNORE INTO "{self.namespace}" '
165
+ "(id, title, content, extra) VALUES (?, ?, ?, ?)"
166
+ )
167
+ self._conn.execute(
168
+ sql,
169
+ (
170
+ data["id"],
171
+ data["title"],
172
+ data["content"],
173
+ json.dumps(data["extra"], ensure_ascii=False),
174
+ ),
175
+ )
176
+ if self._batch_size == 1:
177
+ self._conn.commit()
178
+ else:
179
+ self._pending += 1
180
+ if self._pending >= self._batch_size:
181
+ self._conn.commit()
182
+ self._pending = 0
183
+
184
+ def _save_many_sql(
185
+ self,
186
+ datas: list[ChapterDict],
187
+ on_exist: SaveMode = "overwrite",
188
+ ) -> None:
189
+ """
190
+ Bulk-insert into SQLite using executemany + one commit.
191
+
192
+ :param datas: List of ChapterDict to store.
193
+ :param on_exist: "overwrite" to REPLACE, "skip" to IGNORE on conflicts.
194
+ """
195
+ if on_exist not in ("overwrite", "skip"):
196
+ raise ValueError(f"invalid on_exist mode: {on_exist!r}")
197
+ if self._conn is None:
198
+ raise RuntimeError("ChapterStorage is closed")
199
+
200
+ sql = (
201
+ f'INSERT OR REPLACE INTO "{self.namespace}" '
202
+ "(id, title, content, extra) VALUES (?, ?, ?, ?)"
203
+ if on_exist == "overwrite"
204
+ else f'INSERT OR IGNORE INTO "{self.namespace}" '
205
+ "(id, title, content, extra) VALUES (?, ?, ?, ?)"
206
+ )
207
+
208
+ params = [
209
+ (
210
+ data["id"],
211
+ data["title"],
212
+ data["content"],
213
+ json.dumps(data["extra"], ensure_ascii=False),
214
+ )
215
+ for data in datas
216
+ ]
217
+
218
+ with self._conn:
219
+ self._conn.executemany(sql, params)
220
+
221
+ def save(
222
+ self,
223
+ data: ChapterDict,
224
+ on_exist: SaveMode = "overwrite",
225
+ ) -> None:
226
+ """
227
+ Save a chapter record.
228
+
229
+ :param data: ChapterDict to store.
230
+ :param on_exist: What to do if chap_id already exists
231
+ """
232
+ if on_exist not in ("overwrite", "skip"):
233
+ raise ValueError(f"invalid on_exist mode: {on_exist!r}")
234
+
235
+ if self.backend == "json":
236
+ self._save_json(data, on_exist)
237
+ else:
238
+ self._save_sql(data, on_exist)
239
+
240
+ def save_many(
241
+ self,
242
+ datas: list[ChapterDict],
243
+ on_exist: SaveMode = "overwrite",
244
+ ) -> None:
245
+ """
246
+ Save multiple chapter records in one shot.
247
+
248
+ :param datas: List of ChapterDict to store.
249
+ :param on_exist: What to do if chap_id already exists.
250
+ """
251
+ if on_exist not in ("overwrite", "skip"):
252
+ raise ValueError(f"invalid on_exist mode: {on_exist!r}")
253
+
254
+ if self.backend == "json":
255
+ for data in datas:
256
+ self._save_json(data, on_exist)
257
+ else:
258
+ self._save_many_sql(datas, on_exist)
259
+
260
+ def list_ids(self) -> list[str]:
261
+ """
262
+ List all stored chapter IDs.
263
+ """
264
+ if self.backend == "json":
265
+ return [p.stem for p in self._json_dir.glob("*.json") if p.is_file()]
266
+
267
+ if self._conn is None:
268
+ raise RuntimeError("ChapterStorage is closed")
269
+ cur = self._conn.execute(f'SELECT id FROM "{self.namespace}"')
270
+ return [row[0] for row in cur.fetchall()]
271
+
272
+ def delete(self, chap_id: str) -> bool:
273
+ """
274
+ Delete a chapter by ID.
275
+
276
+ :param chap_id: Chapter identifier.
277
+ :return: True if deleted, False if not found.
278
+ """
279
+ if not self.exists(chap_id):
280
+ return False
281
+ if self.backend == "json":
282
+ self._json_path(chap_id).unlink()
283
+ return True
284
+
285
+ if self._conn is None:
286
+ raise RuntimeError("ChapterStorage is closed")
287
+ cur = self._conn.execute(
288
+ f'DELETE FROM "{self.namespace}" WHERE id = ?', (chap_id,)
289
+ )
290
+ self._conn.commit()
291
+ return cur.rowcount > 0
292
+
293
+ def count(self) -> int:
294
+ """
295
+ Count total chapters stored.
296
+ """
297
+ if self.backend == "json":
298
+ return len(self.list_ids())
299
+
300
+ if self._conn is None:
301
+ raise RuntimeError("ChapterStorage is closed")
302
+ cur = self._conn.execute(f'SELECT COUNT(1) FROM "{self.namespace}"')
303
+ return int(cur.fetchone()[0])
304
+
305
+ def flush(self) -> None:
306
+ """
307
+ Write out any leftover rows (< batch_size) at the end.
308
+ """
309
+ if self._conn is not None and self._pending > 0:
310
+ self._conn.commit()
311
+ self._pending = 0
312
+
313
+ def close(self) -> None:
314
+ """
315
+ Gracefully close any open resources.
316
+ """
317
+ if self.backend != "sqlite" or self._conn is None:
318
+ return
319
+
320
+ with contextlib.suppress(Exception):
321
+ self.flush()
322
+
323
+ with contextlib.suppress(Exception):
324
+ self._conn.close()
325
+
326
+ self._conn = None
327
+
328
+ def __enter__(self) -> Self:
329
+ return self
330
+
331
+ def __exit__(
332
+ self,
333
+ exc_type: type[BaseException] | None,
334
+ exc_val: BaseException | None,
335
+ tb: types.TracebackType | None,
336
+ ) -> None:
337
+ self.close()
338
+
339
+ def __del__(self) -> None:
340
+ self.close()
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.constants
5
4
  --------------------------------
@@ -20,6 +19,10 @@ APP_NAME = "NovelDownloader" # Display name
20
19
  APP_DIR_NAME = "novel_downloader" # Directory name for platformdirs
21
20
  LOGGER_NAME = PACKAGE_NAME # Root logger name
22
21
 
22
+ SUPPORTED_SITES = {
23
+ "qidian",
24
+ "biquge",
25
+ }
23
26
 
24
27
  # -----------------------------------------------------------------------------
25
28
  # Base directories
@@ -33,7 +36,6 @@ LOCALES_DIR: Path = PACKAGE_ROOT / "locales"
33
36
  # Subdirectories under BASE_CONFIG_DIR
34
37
  LOGGER_DIR = WORK_DIR / "logs"
35
38
  JS_SCRIPT_DIR = BASE_CONFIG_DIR / "scripts"
36
- STATE_DIR = BASE_CONFIG_DIR / "state"
37
39
  DATA_DIR = BASE_CONFIG_DIR / "data"
38
40
  CONFIG_DIR = BASE_CONFIG_DIR / "config"
39
41
  MODEL_CACHE_DIR = BASE_CONFIG_DIR / "models"
@@ -41,7 +43,7 @@ MODEL_CACHE_DIR = BASE_CONFIG_DIR / "models"
41
43
  # -----------------------------------------------------------------------------
42
44
  # Default file paths
43
45
  # -----------------------------------------------------------------------------
44
- STATE_FILE = STATE_DIR / "state.json"
46
+ STATE_FILE = DATA_DIR / "state.json"
45
47
  HASH_STORE_FILE = DATA_DIR / "image_hashes.json"
46
48
  SETTING_FILE = CONFIG_DIR / "settings.json"
47
49
  SITE_RULES_FILE = CONFIG_DIR / "site_rules.json"
@@ -76,7 +78,7 @@ DEFAULT_USER_HEADERS = {
76
78
  # -----------------------------------------------------------------------------
77
79
  # Embedded resources (via importlib.resources)
78
80
  # -----------------------------------------------------------------------------
79
- BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.yaml")
81
+ BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.toml")
80
82
  BASE_RULE_PATH = files("novel_downloader.resources.config").joinpath("rules.toml")
81
83
 
82
84
  DEFAULT_SETTINGS_PATHS = [
@@ -12,7 +12,7 @@ import hashlib
12
12
  import json
13
13
  import random
14
14
  import time
15
- from typing import Any, Dict, List
15
+ from typing import Any
16
16
 
17
17
 
18
18
  def rc4_crypt(
@@ -50,7 +50,7 @@ def rc4_crypt(
50
50
 
51
51
  # Pseudo-Random Generation Algorithm (PRGA)
52
52
  i = j = 0
53
- out: List[int] = []
53
+ out: list[int] = []
54
54
  for char in data_bytes:
55
55
  i = (i + 1) % 256
56
56
  j = (j + S[i]) % 256
@@ -110,7 +110,7 @@ def patch_qd_payload_token(
110
110
 
111
111
  # Step 1 - decrypt --------------------------------------------------
112
112
  decrypted_json: str = rc4_crypt(key, enc_token, mode="decrypt")
113
- payload: Dict[str, Any] = json.loads(decrypted_json)
113
+ payload: dict[str, Any] = json.loads(decrypted_json)
114
114
 
115
115
  # Step 2 - rebuild timing fields -----------------------------------
116
116
  loadts = int(time.time() * 1000) # ms since epoch
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.file_utils
5
4
  ---------------------------------
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.file_utils.io
5
4
  ------------------------------------
@@ -17,7 +16,7 @@ import logging
17
16
  import tempfile
18
17
  from importlib.resources import files
19
18
  from pathlib import Path
20
- from typing import Any, Dict, List, Literal, Optional, Set, Union
19
+ from typing import Any, Literal
21
20
 
22
21
  from .sanitize import sanitize_filename
23
22
 
@@ -41,9 +40,9 @@ def _get_non_conflicting_path(path: Path) -> Path:
41
40
 
42
41
 
43
42
  def _write_file(
44
- content: Union[str, bytes, Dict[Any, Any], List[Any], Any],
45
- filepath: Union[str, Path],
46
- mode: Optional[str] = None,
43
+ content: str | bytes | dict[Any, Any] | list[Any] | Any,
44
+ filepath: str | Path,
45
+ mode: str | None = None,
47
46
  *,
48
47
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
49
48
  dump_json: bool = False,
@@ -78,7 +77,7 @@ def _write_file(
78
77
  logger.debug("[file] '%s' exists, will overwrite", path)
79
78
 
80
79
  # Prepare content and write mode
81
- content_to_write: Union[str, bytes]
80
+ content_to_write: str | bytes
82
81
  if dump_json:
83
82
  # Serialize original object to JSON string
84
83
  json_str = json.dumps(content, ensure_ascii=False, indent=2)
@@ -87,7 +86,7 @@ def _write_file(
87
86
  content_to_write = json_str
88
87
  write_mode = "w"
89
88
  else:
90
- if isinstance(content, (str, bytes)):
89
+ if isinstance(content, (str | bytes)):
91
90
  content_to_write = content
92
91
  else:
93
92
  raise TypeError("Non-JSON content must be str or bytes.")
@@ -113,7 +112,7 @@ def _write_file(
113
112
 
114
113
  def save_as_txt(
115
114
  content: str,
116
- filepath: Union[str, Path],
115
+ filepath: str | Path,
117
116
  *,
118
117
  encoding: str = "utf-8",
119
118
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
@@ -139,7 +138,7 @@ def save_as_txt(
139
138
 
140
139
  def save_as_json(
141
140
  content: Any,
142
- filepath: Union[str, Path],
141
+ filepath: str | Path,
143
142
  *,
144
143
  encoding: str = "utf-8",
145
144
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
@@ -163,9 +162,7 @@ def save_as_json(
163
162
  )
164
163
 
165
164
 
166
- def read_text_file(
167
- filepath: Union[str, Path], encoding: str = "utf-8"
168
- ) -> Optional[str]:
165
+ def read_text_file(filepath: str | Path, encoding: str = "utf-8") -> str | None:
169
166
  """
170
167
  Read a UTF-8 text file.
171
168
 
@@ -181,9 +178,7 @@ def read_text_file(
181
178
  return None
182
179
 
183
180
 
184
- def read_json_file(
185
- filepath: Union[str, Path], encoding: str = "utf-8"
186
- ) -> Optional[Any]:
181
+ def read_json_file(filepath: str | Path, encoding: str = "utf-8") -> Any | None:
187
182
  """
188
183
  Read a JSON file and parse it into Python objects.
189
184
 
@@ -199,7 +194,7 @@ def read_json_file(
199
194
  return None
200
195
 
201
196
 
202
- def read_binary_file(filepath: Union[str, Path]) -> Optional[bytes]:
197
+ def read_binary_file(filepath: str | Path) -> bytes | None:
203
198
  """
204
199
  Read a binary file and return its content as bytes.
205
200
 
@@ -231,7 +226,7 @@ def load_text_resource(
231
226
  return resource_path.read_text(encoding="utf-8")
232
227
 
233
228
 
234
- def load_blacklisted_words() -> Set[str]:
229
+ def load_blacklisted_words() -> set[str]:
235
230
  """
236
231
  Convenience loader for the blacklist.txt in the text resources.
237
232
 
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.file_utils.normalize
5
4
  -------------------------------------------
@@ -12,12 +11,11 @@ Currently includes line-ending normalization for .txt files.
12
11
 
13
12
  import logging
14
13
  from pathlib import Path
15
- from typing import Union
16
14
 
17
15
  logger = logging.getLogger(__name__)
18
16
 
19
17
 
20
- def normalize_txt_line_endings(folder_path: Union[str, Path]) -> None:
18
+ def normalize_txt_line_endings(folder_path: str | Path) -> None:
21
19
  """
22
20
  Convert all .txt files in the given folder (recursively)
23
21
  to use Unix-style LF (\\n) line endings.
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.file_utils.sanitize
5
4
  ------------------------------------------
@@ -15,7 +14,6 @@ lengths, and avoids reserved names on Windows systems.
15
14
  import logging
16
15
  import os
17
16
  import re
18
- from typing import Optional
19
17
 
20
18
  logger = logging.getLogger(__name__)
21
19
 
@@ -33,7 +31,7 @@ _SANITIZE_PATTERN_WIN = re.compile(r'[<>:"/\\|?*\x00-\x1F]')
33
31
  _SANITIZE_PATTERN_POSIX = re.compile(r"[/\x00]")
34
32
 
35
33
 
36
- def sanitize_filename(filename: str, max_length: Optional[int] = 255) -> str:
34
+ def sanitize_filename(filename: str, max_length: int | None = 255) -> str:
37
35
  """
38
36
  Sanitize the given filename by replacing characters
39
37
  that are invalid in file paths with '_'.
@@ -47,12 +45,7 @@ def sanitize_filename(filename: str, max_length: Optional[int] = 255) -> str:
47
45
  :param max_length: Optional maximum length of the output filename. Defaults to 255.
48
46
  :return: The sanitized filename as a string.
49
47
  """
50
- if os.name == "nt":
51
- # Windows: invalid characters in filenames are: <>:"/\\|?*
52
- pattern = _SANITIZE_PATTERN_WIN
53
- else:
54
- # POSIX systems: the forward slash is not allowed
55
- pattern = _SANITIZE_PATTERN_POSIX
48
+ pattern = _SANITIZE_PATTERN_WIN if os.name == "nt" else _SANITIZE_PATTERN_POSIX
56
49
 
57
50
  name = pattern.sub("_", filename).strip(" .")
58
51
 
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.utils.fontocr
5
4
  ------------------------------