novel-downloader 1.4.1__py3-none-any.whl → 1.4.2__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 -1
- novel_downloader/cli/download.py +69 -10
- novel_downloader/config/adapter.py +42 -9
- novel_downloader/core/downloaders/base.py +26 -22
- novel_downloader/core/downloaders/common.py +41 -5
- novel_downloader/core/downloaders/qidian.py +60 -32
- novel_downloader/core/exporters/common/epub.py +153 -68
- novel_downloader/core/exporters/epub_util.py +1358 -0
- novel_downloader/core/exporters/linovelib/epub.py +147 -190
- novel_downloader/core/fetchers/qidian/browser.py +62 -10
- novel_downloader/core/interfaces/downloader.py +13 -12
- novel_downloader/locales/en.json +2 -0
- novel_downloader/locales/zh.json +2 -0
- novel_downloader/models/__init__.py +2 -0
- novel_downloader/models/config.py +8 -0
- novel_downloader/tui/screens/home.py +5 -4
- novel_downloader/utils/constants.py +0 -29
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/METADATA +4 -2
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/RECORD +23 -28
- novel_downloader/core/exporters/epub_utils/__init__.py +0 -40
- novel_downloader/core/exporters/epub_utils/css_builder.py +0 -75
- novel_downloader/core/exporters/epub_utils/image_loader.py +0 -131
- novel_downloader/core/exporters/epub_utils/initializer.py +0 -100
- novel_downloader/core/exporters/epub_utils/text_to_html.py +0 -178
- novel_downloader/core/exporters/epub_utils/volume_intro.py +0 -60
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/WHEEL +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/top_level.txt +0 -0
@@ -12,24 +12,17 @@ import html
|
|
12
12
|
import json
|
13
13
|
import re
|
14
14
|
from pathlib import Path
|
15
|
-
from typing import TYPE_CHECKING
|
15
|
+
from typing import TYPE_CHECKING
|
16
16
|
|
17
|
-
from
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
chapter_txt_to_html,
|
23
|
-
create_css_items,
|
24
|
-
create_volume_intro,
|
25
|
-
init_epub,
|
17
|
+
from novel_downloader.core.exporters.epub_util import (
|
18
|
+
Book,
|
19
|
+
Chapter,
|
20
|
+
StyleSheet,
|
21
|
+
Volume,
|
26
22
|
)
|
27
23
|
from novel_downloader.utils.constants import (
|
24
|
+
CSS_MAIN_PATH,
|
28
25
|
DEFAULT_HEADERS,
|
29
|
-
EPUB_IMAGE_FOLDER,
|
30
|
-
EPUB_IMAGE_WRAPPER,
|
31
|
-
EPUB_OPTIONS,
|
32
|
-
EPUB_TEXT_FOLDER,
|
33
26
|
)
|
34
27
|
from novel_downloader.utils.file_utils import sanitize_filename
|
35
28
|
from novel_downloader.utils.network import download_image
|
@@ -37,9 +30,15 @@ from novel_downloader.utils.network import download_image
|
|
37
30
|
if TYPE_CHECKING:
|
38
31
|
from .main_exporter import LinovelibExporter
|
39
32
|
|
33
|
+
_IMAGE_WRAPPER = (
|
34
|
+
'<div class="duokan-image-single illus"><img src="../Images/{filename}" /></div>'
|
35
|
+
)
|
40
36
|
_IMG_TAG_PATTERN = re.compile(
|
41
37
|
r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
|
42
38
|
)
|
39
|
+
_RAW_HTML_RE = re.compile(
|
40
|
+
r'^(<img\b[^>]*?\/>|<div class="duokan-image-single illus">.*?<\/div>)$', re.DOTALL
|
41
|
+
)
|
43
42
|
_IMG_HEADERS = DEFAULT_HEADERS.copy()
|
44
43
|
_IMG_HEADERS["Referer"] = "https://www.linovelib.com/"
|
45
44
|
|
@@ -79,12 +78,12 @@ def export_whole_book(
|
|
79
78
|
return
|
80
79
|
|
81
80
|
book_name = book_info.get("book_name", book_id)
|
81
|
+
book_author = book_info.get("author", "")
|
82
82
|
exporter.logger.info(
|
83
83
|
"%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
|
84
84
|
)
|
85
85
|
|
86
86
|
# --- Generate intro + cover ---
|
87
|
-
intro_html = _generate_intro_html(book_info)
|
88
87
|
cover_path: Path | None = None
|
89
88
|
cover_url = book_info.get("cover_url", "")
|
90
89
|
if config.include_cover and cover_url:
|
@@ -99,63 +98,56 @@ def export_whole_book(
|
|
99
98
|
exporter.logger.warning("Failed to download cover from %s", cover_url)
|
100
99
|
|
101
100
|
# --- Initialize EPUB ---
|
102
|
-
book
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
101
|
+
book = Book(
|
102
|
+
title=book_name,
|
103
|
+
author=book_author,
|
104
|
+
description=book_info.get("summary", ""),
|
105
|
+
cover_path=cover_path,
|
106
|
+
subject=book_info.get("subject", []),
|
107
|
+
serial_status=book_info.get("serial_status", ""),
|
108
|
+
word_count=book_info.get("word_count", ""),
|
109
|
+
uid=f"{exporter.site}_{book_id}",
|
108
110
|
)
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
111
|
+
main_css = StyleSheet(
|
112
|
+
id="main_style",
|
113
|
+
content=CSS_MAIN_PATH.read_text(encoding="utf-8"),
|
114
|
+
filename="main.css",
|
115
|
+
)
|
116
|
+
book.add_stylesheet(main_css)
|
114
117
|
|
115
118
|
# --- Compile chapters ---
|
116
119
|
volumes = book_info.get("volumes", [])
|
117
120
|
for vol_index, vol in enumerate(volumes, start=1):
|
118
|
-
|
119
|
-
|
121
|
+
raw_vol_name = vol.get("volume_name", "")
|
122
|
+
raw_vol_name = raw_vol_name.replace(book_name, "").strip()
|
123
|
+
vol_name = raw_vol_name or f"Volume {vol_index}"
|
124
|
+
exporter.logger.info("Processing volume %d: %s", vol_index, vol_name)
|
125
|
+
|
120
126
|
vol_cover_path: Path | None = None
|
121
127
|
vol_cover_url = vol.get("volume_cover", "")
|
122
|
-
if
|
128
|
+
if vol_cover_url:
|
123
129
|
vol_cover_path = download_image(
|
124
130
|
vol_cover_url,
|
125
131
|
img_dir,
|
126
|
-
headers=_IMG_HEADERS,
|
127
132
|
on_exist="skip",
|
128
133
|
)
|
129
134
|
|
130
|
-
|
131
|
-
|
132
|
-
# Volume intro
|
133
|
-
vol_intro = epub.EpubHtml(
|
135
|
+
curr_vol = Volume(
|
136
|
+
id=f"vol_{vol_index}",
|
134
137
|
title=vol_name,
|
135
|
-
|
136
|
-
|
138
|
+
intro=vol.get("volume_intro", ""),
|
139
|
+
cover=vol_cover_path,
|
137
140
|
)
|
138
|
-
vol_intro.content = _generate_vol_intro_html(
|
139
|
-
vol_name,
|
140
|
-
vol.get("volume_intro", ""),
|
141
|
-
vol_cover_path,
|
142
|
-
)
|
143
|
-
vol_intro.add_link(
|
144
|
-
href="../Styles/volume-intro.css",
|
145
|
-
rel="stylesheet",
|
146
|
-
type="text/css",
|
147
|
-
)
|
148
|
-
book.add_item(vol_intro)
|
149
|
-
spine.append(vol_intro)
|
150
|
-
|
151
|
-
section = epub.Section(vol_name, vol_intro.file_name)
|
152
|
-
chapter_items: list[epub.EpubHtml] = []
|
153
141
|
|
154
142
|
for chap in vol.get("chapters", []):
|
155
143
|
chap_id = chap.get("chapterId")
|
156
144
|
chap_title = chap.get("title", "")
|
157
145
|
if not chap_id:
|
158
|
-
exporter.logger.warning(
|
146
|
+
exporter.logger.warning(
|
147
|
+
"%s Missing chapterId, skipping: %s",
|
148
|
+
TAG,
|
149
|
+
chap,
|
150
|
+
)
|
159
151
|
continue
|
160
152
|
|
161
153
|
chapter_data = exporter._get_chapter(book_id, chap_id)
|
@@ -168,38 +160,30 @@ def export_whole_book(
|
|
168
160
|
)
|
169
161
|
continue
|
170
162
|
|
171
|
-
title = chapter_data.get("title"
|
163
|
+
title = chapter_data.get("title") or chap_id
|
172
164
|
content: str = chapter_data.get("content", "")
|
173
|
-
content,
|
174
|
-
chap_html =
|
165
|
+
content, img_paths = _inline_remote_images(content, img_dir)
|
166
|
+
chap_html = _txt_to_html(
|
175
167
|
chapter_title=title,
|
176
168
|
chapter_text=content,
|
177
|
-
|
169
|
+
extras={
|
170
|
+
"作者说": chapter_data.get("author_say", ""),
|
171
|
+
},
|
178
172
|
)
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
type="text/css",
|
173
|
+
curr_vol.add_chapter(
|
174
|
+
Chapter(
|
175
|
+
id=f"c_{chap_id}",
|
176
|
+
title=title,
|
177
|
+
content=chap_html,
|
178
|
+
css=[main_css],
|
179
|
+
)
|
187
180
|
)
|
188
|
-
|
189
|
-
|
190
|
-
chapter_items.append(item)
|
191
|
-
|
192
|
-
toc_list.append((section, chapter_items))
|
181
|
+
for img_path in img_paths:
|
182
|
+
book.add_image(img_path)
|
193
183
|
|
194
|
-
|
184
|
+
book.add_volume(curr_vol)
|
195
185
|
|
196
186
|
# --- 5. Finalize EPUB ---
|
197
|
-
exporter.logger.info("%s Building TOC and spine...", TAG)
|
198
|
-
book.toc = toc_list
|
199
|
-
book.spine = spine
|
200
|
-
book.add_item(epub.EpubNcx())
|
201
|
-
book.add_item(epub.EpubNav())
|
202
|
-
|
203
187
|
out_name = exporter.get_filename(
|
204
188
|
title=book_name,
|
205
189
|
author=book_info.get("author"),
|
@@ -208,7 +192,7 @@ def export_whole_book(
|
|
208
192
|
out_path = out_dir / sanitize_filename(out_name)
|
209
193
|
|
210
194
|
try:
|
211
|
-
|
195
|
+
book.export(out_path)
|
212
196
|
exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
|
213
197
|
except Exception as e:
|
214
198
|
exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
|
@@ -243,18 +227,25 @@ def export_by_volume(
|
|
243
227
|
return
|
244
228
|
|
245
229
|
book_name = book_info.get("book_name", book_id)
|
230
|
+
book_author = book_info.get("author", "")
|
231
|
+
book_summary = book_info.get("summary", "")
|
246
232
|
exporter.logger.info(
|
247
233
|
"%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
|
248
234
|
)
|
249
|
-
|
250
|
-
|
251
|
-
|
235
|
+
|
236
|
+
main_css = StyleSheet(
|
237
|
+
id="main_style",
|
238
|
+
content=CSS_MAIN_PATH.read_text(encoding="utf-8"),
|
239
|
+
filename="main.css",
|
252
240
|
)
|
253
241
|
|
254
242
|
# --- Compile columes ---
|
255
243
|
volumes = book_info.get("volumes", [])
|
256
244
|
for vol_index, vol in enumerate(volumes, start=1):
|
257
|
-
|
245
|
+
raw_vol_name = vol.get("volume_name", "")
|
246
|
+
raw_vol_name = raw_vol_name.replace(book_name, "").strip()
|
247
|
+
vol_name = raw_vol_name or f"Volume {vol_index}"
|
248
|
+
|
258
249
|
vol_cover_path: Path | None = None
|
259
250
|
vol_cover_url = vol.get("volume_cover", "")
|
260
251
|
if config.include_cover and vol_cover_url:
|
@@ -264,23 +255,28 @@ def export_by_volume(
|
|
264
255
|
headers=_IMG_HEADERS,
|
265
256
|
on_exist="skip",
|
266
257
|
)
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
258
|
+
|
259
|
+
book = Book(
|
260
|
+
title=vol_name,
|
261
|
+
author=book_author,
|
262
|
+
description=vol.get("volume_intro") or book_summary,
|
263
|
+
cover_path=vol_cover_path,
|
264
|
+
subject=book_info.get("subject", []),
|
265
|
+
serial_status=vol.get("serial_status", ""),
|
266
|
+
word_count=vol.get("word_count", ""),
|
267
|
+
uid=f"{exporter.site}_{book_id}_v{vol_index}",
|
275
268
|
)
|
276
|
-
|
277
|
-
book.add_item(css)
|
269
|
+
book.add_stylesheet(main_css)
|
278
270
|
|
279
271
|
for chap in vol.get("chapters", []):
|
280
272
|
chap_id = chap.get("chapterId")
|
281
273
|
chap_title = chap.get("title", "")
|
282
274
|
if not chap_id:
|
283
|
-
exporter.logger.warning(
|
275
|
+
exporter.logger.warning(
|
276
|
+
"%s Missing chapterId, skipping: %s",
|
277
|
+
TAG,
|
278
|
+
chap,
|
279
|
+
)
|
284
280
|
continue
|
285
281
|
|
286
282
|
chapter_data = exporter._get_chapter(book_id, chap_id)
|
@@ -296,29 +292,21 @@ def export_by_volume(
|
|
296
292
|
title = chapter_data.get("title", "") or chap_id
|
297
293
|
content: str = chapter_data.get("content", "")
|
298
294
|
content, imgs = _inline_remote_images(content, img_dir)
|
299
|
-
chap_html =
|
295
|
+
chap_html = _txt_to_html(
|
300
296
|
chapter_title=title,
|
301
297
|
chapter_text=content,
|
302
|
-
|
298
|
+
extras={},
|
303
299
|
)
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
rel="stylesheet",
|
312
|
-
type="text/css",
|
300
|
+
book.add_chapter(
|
301
|
+
Chapter(
|
302
|
+
id=f"c_{chap_id}",
|
303
|
+
title=title,
|
304
|
+
content=chap_html,
|
305
|
+
css=[main_css],
|
306
|
+
)
|
313
307
|
)
|
314
|
-
|
315
|
-
|
316
|
-
toc_list.append(item)
|
317
|
-
|
318
|
-
book.toc = toc_list
|
319
|
-
book.spine = spine
|
320
|
-
book.add_item(epub.EpubNcx())
|
321
|
-
book.add_item(epub.EpubNav())
|
308
|
+
for img_path in imgs:
|
309
|
+
book.add_image(img_path)
|
322
310
|
|
323
311
|
out_name = exporter.get_filename(
|
324
312
|
title=vol_name,
|
@@ -328,96 +316,20 @@ def export_by_volume(
|
|
328
316
|
out_path = out_dir / sanitize_filename(out_name)
|
329
317
|
|
330
318
|
try:
|
331
|
-
|
319
|
+
book.export(out_path)
|
332
320
|
exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
|
333
321
|
except Exception as e:
|
334
322
|
exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
|
335
323
|
return
|
336
324
|
|
337
325
|
|
338
|
-
def _generate_intro_html(
|
339
|
-
info: dict[str, Any],
|
340
|
-
default_author: str = "",
|
341
|
-
) -> str:
|
342
|
-
"""
|
343
|
-
Generate an HTML snippet containing book metadata and summary.
|
344
|
-
|
345
|
-
:param info: A dict that may contain book info
|
346
|
-
:param default_author: Fallback author name.
|
347
|
-
|
348
|
-
:return: An HTML-formatted string.
|
349
|
-
"""
|
350
|
-
title = info.get("book_name") or info.get("volume_name")
|
351
|
-
author = info.get("author") or default_author
|
352
|
-
status = info.get("serial_status")
|
353
|
-
words = info.get("word_count")
|
354
|
-
raw_summary = (info.get("summary") or info.get("volume_intro") or "").strip()
|
355
|
-
|
356
|
-
html_parts = [
|
357
|
-
"<h1>书籍简介</h1>",
|
358
|
-
'<div class="list">',
|
359
|
-
"<ul>",
|
360
|
-
]
|
361
|
-
metadata = [
|
362
|
-
("书名", title),
|
363
|
-
("作者", author),
|
364
|
-
("状态", status),
|
365
|
-
("字数", words),
|
366
|
-
]
|
367
|
-
for label, value in metadata:
|
368
|
-
if value is not None and str(value).strip():
|
369
|
-
safe = html.escape(str(value))
|
370
|
-
if label == "书名":
|
371
|
-
safe = f"《{safe}》"
|
372
|
-
html_parts.append(f"<li>{label}: {safe}</li>")
|
373
|
-
|
374
|
-
html_parts.extend(["</ul>", "</div>"])
|
375
|
-
|
376
|
-
if raw_summary:
|
377
|
-
html_parts.append('<p class="new-page-after"><br/></p>')
|
378
|
-
html_parts.append("<h2>简介</h2>")
|
379
|
-
for para in filter(None, (p.strip() for p in raw_summary.split("\n\n"))):
|
380
|
-
safe_para = html.escape(para).replace("\n", "<br/>")
|
381
|
-
html_parts.append(f"<p>{safe_para}</p>")
|
382
|
-
|
383
|
-
return "\n".join(html_parts)
|
384
|
-
|
385
|
-
|
386
|
-
def _generate_vol_intro_html(
|
387
|
-
title: str,
|
388
|
-
intro: str = "",
|
389
|
-
cover_path: Path | None = None,
|
390
|
-
) -> str:
|
391
|
-
"""
|
392
|
-
Generate the HTML snippet for a volume's introduction section.
|
393
|
-
|
394
|
-
:param title: Title of the volume.
|
395
|
-
:param intro: Optional introduction text for the volume.
|
396
|
-
:param cover_path: Path of the volume cover.
|
397
|
-
:return: HTML string representing the volume's intro section.
|
398
|
-
"""
|
399
|
-
if cover_path is None:
|
400
|
-
return create_volume_intro(title, intro)
|
401
|
-
|
402
|
-
html_parts = [
|
403
|
-
f'<h1 class="volume-title-line1">{title}</h1>',
|
404
|
-
f'<img class="width100" src="../{EPUB_IMAGE_FOLDER}/{cover_path.name}" />',
|
405
|
-
'<p class="new-page-after"><br/></p>',
|
406
|
-
]
|
407
|
-
|
408
|
-
if intro.strip():
|
409
|
-
html_parts.append(f'<p class="intro">{intro}</p>')
|
410
|
-
|
411
|
-
return "\n".join(html_parts)
|
412
|
-
|
413
|
-
|
414
326
|
def _inline_remote_images(
|
415
327
|
content: str,
|
416
328
|
image_dir: str | Path,
|
417
329
|
) -> tuple[str, list[Path]]:
|
418
330
|
"""
|
419
331
|
Download every remote `<img src="...">` in `content` into `image_dir`,
|
420
|
-
and replace the original tag with
|
332
|
+
and replace the original tag with _IMAGE_WRAPPER
|
421
333
|
pointing to the local filename.
|
422
334
|
|
423
335
|
:param content: HTML/text of the chapter containing <img> tags.
|
@@ -441,9 +353,54 @@ def _inline_remote_images(
|
|
441
353
|
return match.group(0)
|
442
354
|
|
443
355
|
downloaded_images.append(local_path)
|
444
|
-
return
|
356
|
+
return _IMAGE_WRAPPER.format(filename=local_path.name)
|
445
357
|
except Exception:
|
446
358
|
return match.group(0)
|
447
359
|
|
448
360
|
modified_content = _IMG_TAG_PATTERN.sub(_replace, content)
|
449
361
|
return modified_content, downloaded_images
|
362
|
+
|
363
|
+
|
364
|
+
def _txt_to_html(
|
365
|
+
chapter_title: str,
|
366
|
+
chapter_text: str,
|
367
|
+
extras: dict[str, str] | None = None,
|
368
|
+
) -> str:
|
369
|
+
"""
|
370
|
+
Convert chapter text and author note to styled HTML.
|
371
|
+
|
372
|
+
:param chapter_title: Title of the chapter.
|
373
|
+
:param chapter_text: Main content of the chapter.
|
374
|
+
:param extras: Optional dict of titles and content, e.g. {"作者说": "text"}.
|
375
|
+
:return: Rendered HTML as a string.
|
376
|
+
"""
|
377
|
+
|
378
|
+
def _render_block(text: str) -> str:
|
379
|
+
lines = (line.strip() for line in text.splitlines() if line.strip())
|
380
|
+
out = []
|
381
|
+
for line in lines:
|
382
|
+
# preserve raw HTML, otherwise wrap in <p>
|
383
|
+
if _RAW_HTML_RE.match(line):
|
384
|
+
out.append(line)
|
385
|
+
else:
|
386
|
+
out.append(f"<p>{html.escape(line)}</p>")
|
387
|
+
return "\n".join(out)
|
388
|
+
|
389
|
+
parts = []
|
390
|
+
parts.append(f"<h2>{html.escape(chapter_title)}</h2>")
|
391
|
+
parts.append(_render_block(chapter_text))
|
392
|
+
|
393
|
+
if extras:
|
394
|
+
for title, note in extras.items():
|
395
|
+
note = note.strip()
|
396
|
+
if not note:
|
397
|
+
continue
|
398
|
+
parts.extend(
|
399
|
+
[
|
400
|
+
"<hr />",
|
401
|
+
f"<p>{html.escape(title)}</p>",
|
402
|
+
_render_block(note),
|
403
|
+
]
|
404
|
+
)
|
405
|
+
|
406
|
+
return "\n".join(parts)
|
@@ -5,6 +5,7 @@ novel_downloader.core.fetchers.qidian.browser
|
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
8
|
+
import asyncio
|
8
9
|
from typing import Any
|
9
10
|
|
10
11
|
from playwright.async_api import Page
|
@@ -189,18 +190,35 @@ class QidianBrowser(BaseBrowser):
|
|
189
190
|
"""
|
190
191
|
try:
|
191
192
|
page = await self.context.new_page()
|
192
|
-
await page.goto(self.HOMEPAGE_URL, wait_until="networkidle")
|
193
193
|
await self._login_auto(page)
|
194
194
|
await self._dismiss_overlay(page)
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
195
|
+
await page.goto(self.HOMEPAGE_URL, wait_until="networkidle")
|
196
|
+
sign_in_elem = await page.query_selector("#login-box .sign-in")
|
197
|
+
sign_out_elem = await page.query_selector("#login-box .sign-out")
|
198
|
+
|
199
|
+
sign_in_class = (
|
200
|
+
(await sign_in_elem.get_attribute("class") or "")
|
201
|
+
if sign_in_elem
|
202
|
+
else ""
|
203
|
+
)
|
204
|
+
sign_out_class = (
|
205
|
+
(await sign_out_elem.get_attribute("class") or "")
|
206
|
+
if sign_out_elem
|
207
|
+
else ""
|
208
|
+
)
|
209
|
+
|
210
|
+
sign_in_hidden = "hidden" in sign_in_class
|
211
|
+
sign_out_hidden = "hidden" in sign_out_class
|
212
|
+
|
213
|
+
await page.close()
|
214
|
+
|
215
|
+
# if sign_in_visible and not sign_out_visible:
|
216
|
+
if not sign_in_hidden and sign_out_hidden:
|
217
|
+
self.logger.debug("[auth] Detected as logged in.")
|
203
218
|
return True
|
219
|
+
else:
|
220
|
+
self.logger.debug("[auth] Detected as not logged in.")
|
221
|
+
return False
|
204
222
|
except Exception as e:
|
205
223
|
self.logger.warning("[auth] Error while checking login status: %s", e)
|
206
224
|
return False
|
@@ -220,7 +238,10 @@ class QidianBrowser(BaseBrowser):
|
|
220
238
|
|
221
239
|
self.logger.debug("[auth] Overlay mask detected; attempting to close.")
|
222
240
|
|
223
|
-
iframe_element = await page.
|
241
|
+
iframe_element = await page.wait_for_selector(
|
242
|
+
"#loginIfr",
|
243
|
+
timeout=timeout * 1000,
|
244
|
+
)
|
224
245
|
if iframe_element is None:
|
225
246
|
self.logger.debug("[auth] Login iframe not found.")
|
226
247
|
return
|
@@ -261,6 +282,37 @@ class QidianBrowser(BaseBrowser):
|
|
261
282
|
btn = await page.query_selector("#login-btn")
|
262
283
|
if btn and await btn.is_visible():
|
263
284
|
await btn.click()
|
285
|
+
tasks = [
|
286
|
+
asyncio.create_task(
|
287
|
+
page.wait_for_selector(
|
288
|
+
"div.mask",
|
289
|
+
timeout=timeout * 1000,
|
290
|
+
)
|
291
|
+
),
|
292
|
+
asyncio.create_task(
|
293
|
+
page.wait_for_selector(
|
294
|
+
"div.qdlogin-wrap",
|
295
|
+
timeout=timeout * 1000,
|
296
|
+
)
|
297
|
+
),
|
298
|
+
asyncio.create_task(
|
299
|
+
page.wait_for_url(
|
300
|
+
lambda url: "login" not in url,
|
301
|
+
timeout=timeout * 1000,
|
302
|
+
)
|
303
|
+
),
|
304
|
+
]
|
305
|
+
done, pending = await asyncio.wait(
|
306
|
+
tasks,
|
307
|
+
timeout=timeout + 1,
|
308
|
+
return_when=asyncio.FIRST_COMPLETED,
|
309
|
+
)
|
310
|
+
for task in pending:
|
311
|
+
task.cancel()
|
312
|
+
if done:
|
313
|
+
self.logger.debug("[auth] Login flow proceeded after button click.")
|
314
|
+
else:
|
315
|
+
self.logger.warning("[auth] Timeout waiting for login to proceed.")
|
264
316
|
except Exception as e:
|
265
317
|
self.logger.debug("[auth] Failed to click login button: %s", e)
|
266
318
|
return
|
@@ -10,45 +10,46 @@ that outlines the expected behavior of any downloader class.
|
|
10
10
|
from collections.abc import Awaitable, Callable
|
11
11
|
from typing import Any, Protocol, runtime_checkable
|
12
12
|
|
13
|
+
from novel_downloader.models import BookConfig
|
14
|
+
|
13
15
|
|
14
16
|
@runtime_checkable
|
15
17
|
class DownloaderProtocol(Protocol):
|
16
18
|
"""
|
17
|
-
Protocol for
|
19
|
+
Protocol for async downloader implementations.
|
18
20
|
|
19
|
-
|
20
|
-
|
21
|
-
as well as optional pre-download hooks.
|
21
|
+
Uses BookConfig (with book_id, optional start_id/end_id/ignore_ids)
|
22
|
+
for both single and batch downloads.
|
22
23
|
"""
|
23
24
|
|
24
25
|
async def download(
|
25
26
|
self,
|
26
|
-
|
27
|
+
book: BookConfig,
|
27
28
|
*,
|
28
29
|
progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
|
29
30
|
**kwargs: Any,
|
30
31
|
) -> None:
|
31
32
|
"""
|
32
|
-
Download
|
33
|
+
Download a single book.
|
33
34
|
|
34
|
-
:param
|
35
|
-
:param progress_hook:
|
35
|
+
:param book: BookConfig with at least 'book_id'.
|
36
|
+
:param progress_hook: Optional async callback after each chapter.
|
36
37
|
args: completed_count, total_count.
|
37
38
|
"""
|
38
39
|
...
|
39
40
|
|
40
41
|
async def download_many(
|
41
42
|
self,
|
42
|
-
|
43
|
+
books: list[BookConfig],
|
43
44
|
*,
|
44
45
|
progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
|
45
46
|
**kwargs: Any,
|
46
47
|
) -> None:
|
47
48
|
"""
|
48
|
-
|
49
|
+
Download multiple books.
|
49
50
|
|
50
|
-
:param
|
51
|
-
:param progress_hook:
|
51
|
+
:param books: List of BookConfig entries.
|
52
|
+
:param progress_hook: Optional async callback after each chapter.
|
52
53
|
args: completed_count, total_count.
|
53
54
|
"""
|
54
55
|
...
|
novel_downloader/locales/en.json
CHANGED
@@ -66,6 +66,8 @@
|
|
66
66
|
"download_downloading": "Downloading book {book_id} from {site}...",
|
67
67
|
"download_prompt_parse": "Parse...",
|
68
68
|
"download_book_ids": "One or more book IDs to process",
|
69
|
+
"download_option_start": "Start chapter ID (applies to the first book ID only)",
|
70
|
+
"download_option_end": "End chapter ID (applies to the first book ID only)",
|
69
71
|
"login_description": "Description",
|
70
72
|
"login_hint": "Hint",
|
71
73
|
"login_manual_prompt": ">> Please complete login in your browser and press Enter to continue...",
|
novel_downloader/locales/zh.json
CHANGED
@@ -66,6 +66,8 @@
|
|
66
66
|
"download_downloading": "正在从 {site} 下载书籍 {book_id}...",
|
67
67
|
"download_prompt_parse": "结束...",
|
68
68
|
"download_book_ids": "要处理的一个或多个小说 ID",
|
69
|
+
"download_option_start": "起始章节 ID (仅用于第一个书籍 ID)",
|
70
|
+
"download_option_end": "结束章节 ID (仅用于第一个书籍 ID)",
|
69
71
|
"login_description": "说明",
|
70
72
|
"login_hint": "提示",
|
71
73
|
"login_manual_prompt": ">> 请在浏览器中完成登录后按回车继续...",
|
@@ -8,6 +8,7 @@ novel_downloader.models
|
|
8
8
|
from .browser import NewContextOptions
|
9
9
|
from .chapter import ChapterDict
|
10
10
|
from .config import (
|
11
|
+
BookConfig,
|
11
12
|
DownloaderConfig,
|
12
13
|
ExporterConfig,
|
13
14
|
FetcherConfig,
|
@@ -39,6 +40,7 @@ from .types import (
|
|
39
40
|
|
40
41
|
__all__ = [
|
41
42
|
"NewContextOptions",
|
43
|
+
"BookConfig",
|
42
44
|
"DownloaderConfig",
|
43
45
|
"ParserConfig",
|
44
46
|
"FetcherConfig",
|