novel-downloader 1.2.1__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.
- novel_downloader/__init__.py +1 -2
- novel_downloader/cli/__init__.py +0 -1
- novel_downloader/cli/clean.py +2 -10
- novel_downloader/cli/download.py +18 -22
- novel_downloader/cli/interactive.py +0 -1
- novel_downloader/cli/main.py +1 -3
- novel_downloader/cli/settings.py +8 -8
- novel_downloader/config/__init__.py +0 -1
- novel_downloader/config/adapter.py +48 -18
- novel_downloader/config/loader.py +116 -108
- novel_downloader/config/models.py +41 -32
- novel_downloader/config/site_rules.py +2 -4
- novel_downloader/core/__init__.py +0 -1
- novel_downloader/core/downloaders/__init__.py +4 -4
- novel_downloader/core/downloaders/base/__init__.py +14 -0
- novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
- novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
- novel_downloader/core/downloaders/biquge/__init__.py +12 -0
- novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
- novel_downloader/core/downloaders/common/__init__.py +14 -0
- novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
- novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +34 -23
- novel_downloader/core/downloaders/qidian/__init__.py +10 -0
- novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +80 -64
- novel_downloader/core/factory/__init__.py +4 -5
- novel_downloader/core/factory/{downloader_factory.py → downloader.py} +36 -35
- novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
- novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
- novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
- novel_downloader/core/interfaces/__init__.py +8 -9
- novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
- novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +26 -12
- novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
- novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
- novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
- novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +34 -17
- novel_downloader/core/parsers/__init__.py +5 -4
- novel_downloader/core/parsers/{base_parser.py → base.py} +20 -11
- novel_downloader/core/parsers/biquge/__init__.py +10 -0
- novel_downloader/core/parsers/biquge/main_parser.py +126 -0
- novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
- novel_downloader/core/parsers/{common_parser → common}/helper.py +20 -18
- novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +41 -49
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +16 -12
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +37 -45
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +16 -12
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +150 -0
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +9 -10
- novel_downloader/core/requesters/__init__.py +9 -5
- novel_downloader/core/requesters/base/__init__.py +16 -0
- novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +180 -73
- novel_downloader/core/requesters/base/browser.py +340 -0
- novel_downloader/core/requesters/base/session.py +364 -0
- novel_downloader/core/requesters/biquge/__init__.py +12 -0
- novel_downloader/core/requesters/biquge/session.py +90 -0
- novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
- novel_downloader/core/requesters/common/async_session.py +96 -0
- novel_downloader/core/requesters/common/session.py +113 -0
- novel_downloader/core/requesters/qidian/__init__.py +21 -0
- novel_downloader/core/requesters/qidian/broswer.py +306 -0
- novel_downloader/core/requesters/qidian/session.py +287 -0
- novel_downloader/core/savers/__init__.py +5 -3
- novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
- novel_downloader/core/savers/biquge.py +25 -0
- novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
- novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +24 -52
- novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
- novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
- novel_downloader/core/savers/epub_utils/__init__.py +0 -1
- novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
- novel_downloader/core/savers/epub_utils/initializer.py +4 -5
- novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
- novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
- novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
- novel_downloader/locales/en.json +12 -4
- novel_downloader/locales/zh.json +9 -1
- novel_downloader/resources/config/settings.toml +88 -0
- novel_downloader/utils/cache.py +2 -2
- novel_downloader/utils/chapter_storage.py +340 -0
- novel_downloader/utils/constants.py +8 -5
- novel_downloader/utils/crypto_utils.py +3 -3
- novel_downloader/utils/file_utils/__init__.py +0 -1
- novel_downloader/utils/file_utils/io.py +12 -17
- novel_downloader/utils/file_utils/normalize.py +1 -3
- novel_downloader/utils/file_utils/sanitize.py +2 -9
- novel_downloader/utils/fontocr/__init__.py +0 -1
- novel_downloader/utils/fontocr/ocr_v1.py +19 -22
- novel_downloader/utils/fontocr/ocr_v2.py +147 -60
- novel_downloader/utils/hash_store.py +19 -20
- novel_downloader/utils/hash_utils.py +0 -1
- novel_downloader/utils/i18n.py +3 -4
- novel_downloader/utils/logger.py +5 -6
- novel_downloader/utils/model_loader.py +5 -8
- novel_downloader/utils/network.py +9 -10
- novel_downloader/utils/state.py +6 -7
- novel_downloader/utils/text_utils/__init__.py +0 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
- novel_downloader/utils/text_utils/diff_display.py +0 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -4
- novel_downloader/utils/text_utils/text_cleaning.py +0 -1
- novel_downloader/utils/time_utils/__init__.py +0 -1
- novel_downloader/utils/time_utils/datetime_utils.py +9 -11
- novel_downloader/utils/time_utils/sleep_utils.py +27 -13
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/METADATA +14 -17
- novel_downloader-1.3.0.dist-info/RECORD +127 -0
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/WHEEL +1 -1
- novel_downloader/core/parsers/qidian_parser/shared/book_info_parser.py +0 -95
- novel_downloader/core/requesters/base_browser.py +0 -210
- novel_downloader/core/requesters/base_session.py +0 -243
- novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
- novel_downloader/core/requesters/common_requester/common_session.py +0 -126
- novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
- novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -377
- novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
- novel_downloader/resources/config/settings.yaml +0 -76
- novel_downloader-1.2.1.dist-info/RECORD +0 -115
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.2.1.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,19 +19,23 @@ 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
|
26
29
|
# -----------------------------------------------------------------------------
|
27
30
|
# Base config directory (e.g. ~/AppData/Local/novel_downloader/)
|
28
31
|
BASE_CONFIG_DIR = Path(user_config_dir(APP_DIR_NAME, appauthor=False))
|
32
|
+
WORK_DIR = Path.cwd()
|
29
33
|
PACKAGE_ROOT: Path = Path(__file__).parent.parent
|
30
34
|
LOCALES_DIR: Path = PACKAGE_ROOT / "locales"
|
31
35
|
|
32
36
|
# Subdirectories under BASE_CONFIG_DIR
|
33
|
-
LOGGER_DIR =
|
37
|
+
LOGGER_DIR = WORK_DIR / "logs"
|
34
38
|
JS_SCRIPT_DIR = BASE_CONFIG_DIR / "scripts"
|
35
|
-
STATE_DIR = BASE_CONFIG_DIR / "state"
|
36
39
|
DATA_DIR = BASE_CONFIG_DIR / "data"
|
37
40
|
CONFIG_DIR = BASE_CONFIG_DIR / "config"
|
38
41
|
MODEL_CACHE_DIR = BASE_CONFIG_DIR / "models"
|
@@ -40,7 +43,7 @@ MODEL_CACHE_DIR = BASE_CONFIG_DIR / "models"
|
|
40
43
|
# -----------------------------------------------------------------------------
|
41
44
|
# Default file paths
|
42
45
|
# -----------------------------------------------------------------------------
|
43
|
-
STATE_FILE =
|
46
|
+
STATE_FILE = DATA_DIR / "state.json"
|
44
47
|
HASH_STORE_FILE = DATA_DIR / "image_hashes.json"
|
45
48
|
SETTING_FILE = CONFIG_DIR / "settings.json"
|
46
49
|
SITE_RULES_FILE = CONFIG_DIR / "site_rules.json"
|
@@ -75,7 +78,7 @@ DEFAULT_USER_HEADERS = {
|
|
75
78
|
# -----------------------------------------------------------------------------
|
76
79
|
# Embedded resources (via importlib.resources)
|
77
80
|
# -----------------------------------------------------------------------------
|
78
|
-
BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.
|
81
|
+
BASE_CONFIG_PATH = files("novel_downloader.resources.config").joinpath("settings.toml")
|
79
82
|
BASE_RULE_PATH = files("novel_downloader.resources.config").joinpath("rules.toml")
|
80
83
|
|
81
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
|
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:
|
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:
|
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.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,
|
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:
|
45
|
-
filepath:
|
46
|
-
mode:
|
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:
|
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
|
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:
|
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:
|
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:
|
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() ->
|
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:
|
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:
|
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
|
|