novelfetch 2.0.9__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.
- core/__init__.py +5 -0
- core/downloader.py +168 -0
- core/epub.py +143 -0
- core/http_client.py +262 -0
- core/library.py +423 -0
- core/paths.py +69 -0
- core/progress.py +201 -0
- core/translation.py +93 -0
- core/utils.py +83 -0
- gui/__init__.py +1 -0
- gui/async_runner.py +100 -0
- gui/data/NotoNaskhArabic.ttf +0 -0
- gui/data/icon.png +0 -0
- gui/kv/chapter_list.kv +86 -0
- gui/kv/download_dialog.kv +47 -0
- gui/kv/download_picker.kv +62 -0
- gui/kv/history.kv +50 -0
- gui/kv/home_tab.kv +208 -0
- gui/kv/novel_list.kv +44 -0
- gui/kv/reader.kv +71 -0
- gui/kv/search_tab.kv +121 -0
- gui/kv/settings_tab.kv +126 -0
- gui/kv/topbar.kv +24 -0
- gui/kv/update.kv +60 -0
- gui/main.py +154 -0
- gui/novelfetch.kv +11 -0
- gui/screens/__init__.py +1 -0
- gui/screens/app_settings.py +82 -0
- gui/screens/browse.py +162 -0
- gui/screens/chapter_list.py +540 -0
- gui/screens/download_dialog.py +133 -0
- gui/screens/download_picker.py +195 -0
- gui/screens/history.py +212 -0
- gui/screens/home_tab.py +1593 -0
- gui/screens/main_screen.py +134 -0
- gui/screens/novel_list.py +43 -0
- gui/screens/reader.py +660 -0
- gui/screens/search_tab.py +278 -0
- gui/screens/settings_tab.py +345 -0
- gui/screens/source_picker.py +73 -0
- gui/screens/theme.py +63 -0
- gui/screens/topbar.py +72 -0
- gui/screens/update.py +490 -0
- gui/screens/utils.py +811 -0
- novelfetch-2.0.9.dist-info/METADATA +256 -0
- novelfetch-2.0.9.dist-info/RECORD +68 -0
- novelfetch-2.0.9.dist-info/WHEEL +5 -0
- novelfetch-2.0.9.dist-info/entry_points.txt +3 -0
- novelfetch-2.0.9.dist-info/licenses/LICENSE +21 -0
- novelfetch-2.0.9.dist-info/top_level.txt +4 -0
- sources/__init__.py +14 -0
- sources/base.py +93 -0
- sources/novelfire.py +266 -0
- sources/novelphoenix.py +295 -0
- sources/royalroad.py +158 -0
- sources/scriblehub.py +264 -0
- sources/wuxiaspot.py +328 -0
- tui/__init__.py +12 -0
- tui/browse.py +340 -0
- tui/download.py +447 -0
- tui/library.py +223 -0
- tui/main.py +46 -0
- tui/main_menu.py +123 -0
- tui/novelfetch.tcss +128 -0
- tui/reader.py +264 -0
- tui/shared.py +112 -0
- tui/source_picker.py +37 -0
- tui/utils.py +3 -0
core/__init__.py
ADDED
core/downloader.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Download orchestrator — shared by both GUI and TUI.
|
|
2
|
+
|
|
3
|
+
This module replaces the duplicated download logic that previously lived in
|
|
4
|
+
:mod:`gui.screens.utils` (``_download_novel``) and :mod:`tui.download`
|
|
5
|
+
(``DownloadProgressScreen._download_all``). It owns the concurrent
|
|
6
|
+
semaphore, chapter‑by‑chapter orchestration, progress reporting, and the
|
|
7
|
+
final metadata/cover write‑back.
|
|
8
|
+
|
|
9
|
+
The public API is a single async function:
|
|
10
|
+
|
|
11
|
+
await download(source, qualified_slug, chapters, title, ...)
|
|
12
|
+
|
|
13
|
+
which returns ``(saved, failed)`` — exactly matching the signatures that
|
|
14
|
+
callers currently expect.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from core.library import (
|
|
21
|
+
read_meta,
|
|
22
|
+
write_chapter,
|
|
23
|
+
write_meta,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DOWNLOAD_CONCURRENCY: int = 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def download(
|
|
30
|
+
source,
|
|
31
|
+
qualified_slug,
|
|
32
|
+
chapters,
|
|
33
|
+
title,
|
|
34
|
+
*,
|
|
35
|
+
translate=False,
|
|
36
|
+
lang="",
|
|
37
|
+
total=None,
|
|
38
|
+
progress_cb=None,
|
|
39
|
+
base_dir=None,
|
|
40
|
+
) -> tuple[int, int]:
|
|
41
|
+
"""Download every chapter of a novel and persist the results.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
source : Source
|
|
46
|
+
The scraper source providing ``read_chapter`` (and ``cover_url`` if
|
|
47
|
+
a cover is needed).
|
|
48
|
+
qualified_slug : str
|
|
49
|
+
The source-qualified slug, e.g. ``royalroad:some-title``.
|
|
50
|
+
chapters : list[dict]
|
|
51
|
+
Chapter entries as returned by :func:`core.utils._get_chapters`.
|
|
52
|
+
title : str
|
|
53
|
+
Novel title (written into meta.json).
|
|
54
|
+
translate : bool
|
|
55
|
+
When True, each chapter is fetched via :func:`source.read_chapter`,
|
|
56
|
+
then translated with :func:`core.translation._translate_text`.
|
|
57
|
+
lang : str
|
|
58
|
+
Translation language code (e.g. ``"ar"``).
|
|
59
|
+
total : int, optional
|
|
60
|
+
Total chapter count for meta.json. Defaults to ``len(chapters)``.
|
|
61
|
+
progress_cb : callable, optional
|
|
62
|
+
``progress_cb(done, saved)`` is called after each chapter completes.
|
|
63
|
+
base_dir : str, optional
|
|
64
|
+
Root of the novels directory. Defaults to the process working
|
|
65
|
+
directory (matching historic ``CWD``-relative behaviour).
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
(saved, failed) : tuple[int, int]
|
|
70
|
+
*saved* counts chapters that were successfully fetched/saved;
|
|
71
|
+
*failed* counts chapters that could not be fetched.
|
|
72
|
+
"""
|
|
73
|
+
novels_dir = os.path.join(base_dir or os.getcwd(), "novels")
|
|
74
|
+
if total is None:
|
|
75
|
+
total = len(chapters)
|
|
76
|
+
saved = 0
|
|
77
|
+
failed = 0
|
|
78
|
+
done = 0
|
|
79
|
+
|
|
80
|
+
sem = asyncio.Semaphore(DOWNLOAD_CONCURRENCY)
|
|
81
|
+
|
|
82
|
+
async def _process(ch):
|
|
83
|
+
nonlocal saved, failed, done
|
|
84
|
+
safe_title = ch["title"].replace("/", "-").replace(" ", "_")
|
|
85
|
+
# Determine the file path for this chapter
|
|
86
|
+
if translate:
|
|
87
|
+
path = os.path.join(novels_dir, qualified_slug, f"{safe_title}_{lang}.txt")
|
|
88
|
+
else:
|
|
89
|
+
path = os.path.join(novels_dir, qualified_slug, f"{safe_title}.txt")
|
|
90
|
+
|
|
91
|
+
# Skip if the chapter file already exists (preserves "already
|
|
92
|
+
# downloaded" behaviour from the former GUI _download_novel).
|
|
93
|
+
if os.path.exists(path):
|
|
94
|
+
done += 1
|
|
95
|
+
if progress_cb is not None:
|
|
96
|
+
progress_cb(done, saved)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
async with sem:
|
|
100
|
+
try:
|
|
101
|
+
if translate:
|
|
102
|
+
lines = await source.read_chapter(ch["url"])
|
|
103
|
+
if not lines:
|
|
104
|
+
failed += 1
|
|
105
|
+
else:
|
|
106
|
+
text = "\n\n".join(lines)
|
|
107
|
+
# Translate using the shared core.translation helper.
|
|
108
|
+
# Import inside the function to avoid triggering the
|
|
109
|
+
# module-level deep_translator dependency.
|
|
110
|
+
try:
|
|
111
|
+
from core.translation import _translate_text as _tt
|
|
112
|
+
|
|
113
|
+
translated = _tt(text, target=lang)
|
|
114
|
+
except ImportError:
|
|
115
|
+
translated = text
|
|
116
|
+
if not translated:
|
|
117
|
+
failed += 1
|
|
118
|
+
else:
|
|
119
|
+
write_chapter(
|
|
120
|
+
qualified_slug,
|
|
121
|
+
ch["title"],
|
|
122
|
+
translated,
|
|
123
|
+
lang=lang,
|
|
124
|
+
base_dir=base_dir,
|
|
125
|
+
)
|
|
126
|
+
saved += 1
|
|
127
|
+
else:
|
|
128
|
+
lines = await source.read_chapter(ch["url"])
|
|
129
|
+
if not lines:
|
|
130
|
+
failed += 1
|
|
131
|
+
else:
|
|
132
|
+
text = "\n\n".join(lines)
|
|
133
|
+
write_chapter(
|
|
134
|
+
qualified_slug,
|
|
135
|
+
ch["title"],
|
|
136
|
+
text,
|
|
137
|
+
lang=None,
|
|
138
|
+
base_dir=base_dir,
|
|
139
|
+
)
|
|
140
|
+
saved += 1
|
|
141
|
+
except Exception:
|
|
142
|
+
failed += 1
|
|
143
|
+
done += 1
|
|
144
|
+
if progress_cb is not None:
|
|
145
|
+
progress_cb(done, saved)
|
|
146
|
+
|
|
147
|
+
await asyncio.gather(*(_process(ch) for ch in chapters))
|
|
148
|
+
|
|
149
|
+
# ---- completion: write meta.json + cover ----
|
|
150
|
+
# Build meta
|
|
151
|
+
try:
|
|
152
|
+
cover = await source.cover_url(
|
|
153
|
+
qualified_slug.split(":", 1)[-1]
|
|
154
|
+
if ":" in qualified_slug
|
|
155
|
+
else qualified_slug
|
|
156
|
+
)
|
|
157
|
+
except Exception:
|
|
158
|
+
cover = ""
|
|
159
|
+
meta = {"title": title, "cover": cover}
|
|
160
|
+
if translate and lang:
|
|
161
|
+
meta["lang"] = lang
|
|
162
|
+
meta["chapters"] = total
|
|
163
|
+
# Merge with existing meta (preserves 'tracked' from prior tracking)
|
|
164
|
+
existing_meta = read_meta(qualified_slug, base_dir)
|
|
165
|
+
existing_meta.update(meta)
|
|
166
|
+
write_meta(qualified_slug, existing_meta, base_dir)
|
|
167
|
+
|
|
168
|
+
return saved, failed
|
core/epub.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import html as _html
|
|
3
|
+
import os
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
import zipfile
|
|
6
|
+
|
|
7
|
+
from ebooklib import epub
|
|
8
|
+
|
|
9
|
+
from core.http_client import get_client
|
|
10
|
+
from core.library import _chapter_sort_key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_epub(path):
|
|
14
|
+
"""Validate EPUB structure. Returns (ok, error_message)."""
|
|
15
|
+
if not os.path.isfile(path) or os.path.getsize(path) == 0:
|
|
16
|
+
return False, "EPUB file is empty or missing"
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
zf = zipfile.ZipFile(path)
|
|
20
|
+
except zipfile.BadZipFile:
|
|
21
|
+
return False, "EPUB is not a valid ZIP archive"
|
|
22
|
+
|
|
23
|
+
with zf:
|
|
24
|
+
names = zf.namelist()
|
|
25
|
+
|
|
26
|
+
opf_name = next((n for n in names if n.endswith(".opf")), None)
|
|
27
|
+
if not opf_name:
|
|
28
|
+
return False, "EPUB missing OPF manifest"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
opf_xml = zf.read(opf_name)
|
|
32
|
+
root = ET.fromstring(opf_xml)
|
|
33
|
+
ns = {"opf": "http://www.idpf.org/2007/opf"}
|
|
34
|
+
spine = root.find(".//opf:spine", ns)
|
|
35
|
+
if spine is None:
|
|
36
|
+
return False, "EPUB has no spine element"
|
|
37
|
+
itemrefs = spine.findall("opf:itemref", ns)
|
|
38
|
+
if not itemrefs:
|
|
39
|
+
return False, "EPUB spine has no entries"
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return False, f"Failed to parse OPF: {e}"
|
|
42
|
+
|
|
43
|
+
manifest = root.find(".//opf:manifest", ns)
|
|
44
|
+
for itemref in itemrefs:
|
|
45
|
+
idref = itemref.get("idref")
|
|
46
|
+
item = (
|
|
47
|
+
manifest.find(f"opf:item[@id='{idref}']", ns)
|
|
48
|
+
if manifest is not None
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
if item is not None:
|
|
52
|
+
href = item.get("href")
|
|
53
|
+
if href and href in names:
|
|
54
|
+
data = zf.read(href)
|
|
55
|
+
if len(data) < 10:
|
|
56
|
+
return False, f"Chapter '{href}' is empty or too small"
|
|
57
|
+
|
|
58
|
+
return True, ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _export_epub(slug, source=None, chapters=None):
|
|
62
|
+
chap_dir = os.path.join("novels", slug)
|
|
63
|
+
raw_slug = slug.split(":", 1)[-1] if ":" in slug else slug
|
|
64
|
+
title_parts = raw_slug.split("/", 1)
|
|
65
|
+
title_stem = title_parts[-1] if len(title_parts) > 1 else title_parts[0]
|
|
66
|
+
title = title_stem.replace("-", " ").title()
|
|
67
|
+
|
|
68
|
+
book = epub.EpubBook()
|
|
69
|
+
book.set_identifier(slug.replace("/", "-").replace(":", "-"))
|
|
70
|
+
book.set_title(title)
|
|
71
|
+
book.set_language("en")
|
|
72
|
+
|
|
73
|
+
# Cover
|
|
74
|
+
cover_data = None
|
|
75
|
+
author = "Unknown"
|
|
76
|
+
if source:
|
|
77
|
+
try:
|
|
78
|
+
url = await source.cover_url(raw_slug)
|
|
79
|
+
if url:
|
|
80
|
+
c = get_client()
|
|
81
|
+
try:
|
|
82
|
+
r = await c.get(url)
|
|
83
|
+
if r.status_code == 200:
|
|
84
|
+
cover_data = r.content
|
|
85
|
+
finally:
|
|
86
|
+
await c.aclose()
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
book.add_author(author)
|
|
90
|
+
if cover_data:
|
|
91
|
+
book.set_cover("cover-image.jpg", cover_data)
|
|
92
|
+
|
|
93
|
+
# Chapters: either in-memory list or read from disk
|
|
94
|
+
if chapters is not None:
|
|
95
|
+
txt_list = list(chapters)
|
|
96
|
+
else:
|
|
97
|
+
if not os.path.isdir(chap_dir):
|
|
98
|
+
return None
|
|
99
|
+
txt_files = []
|
|
100
|
+
for root, _dirs, files in os.walk(chap_dir):
|
|
101
|
+
for f in sorted(files):
|
|
102
|
+
if f.endswith(".txt"):
|
|
103
|
+
rel = os.path.relpath(os.path.join(root, f), chap_dir)
|
|
104
|
+
txt_files.append(rel)
|
|
105
|
+
txt_files.sort(key=_chapter_sort_key)
|
|
106
|
+
txt_list = []
|
|
107
|
+
for fname in txt_files:
|
|
108
|
+
with open(os.path.join(chap_dir, fname), encoding="utf-8") as f:
|
|
109
|
+
content = f.read()
|
|
110
|
+
txt_list.append((fname, content))
|
|
111
|
+
|
|
112
|
+
if not txt_list:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
epub_chapters = []
|
|
116
|
+
for i, (fname, content) in enumerate(txt_list):
|
|
117
|
+
ch_title = (
|
|
118
|
+
os.path.splitext(os.path.basename(fname))[0].replace("_", " ").title()
|
|
119
|
+
)
|
|
120
|
+
paragraphs = content.split("\n\n")
|
|
121
|
+
parts = [f"<p>{_html.escape(p.strip())}</p>" for p in paragraphs if p.strip()]
|
|
122
|
+
html = f"<h1>{_html.escape(ch_title)}</h1>" + "".join(parts)
|
|
123
|
+
ch = epub.EpubHtml(
|
|
124
|
+
title=ch_title, file_name=f"chap_{i + 1:04d}.xhtml", lang="en"
|
|
125
|
+
)
|
|
126
|
+
ch.content = html
|
|
127
|
+
book.add_item(ch)
|
|
128
|
+
epub_chapters.append(ch)
|
|
129
|
+
|
|
130
|
+
book.toc = epub_chapters
|
|
131
|
+
book.spine = ["nav"] + epub_chapters
|
|
132
|
+
book.add_item(epub.EpubNcx())
|
|
133
|
+
book.add_item(epub.EpubNav())
|
|
134
|
+
|
|
135
|
+
safe = title.replace(" ", "_").replace("/", "-")
|
|
136
|
+
out = os.path.join(chap_dir, f"{safe}.epub")
|
|
137
|
+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
138
|
+
await asyncio.to_thread(epub.write_epub, out, book, {})
|
|
139
|
+
ok, err = _validate_epub(out)
|
|
140
|
+
if not ok:
|
|
141
|
+
os.remove(out)
|
|
142
|
+
return None
|
|
143
|
+
return out
|
core/http_client.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Shared httpx.AsyncClient factory with Android SSL workarounds.
|
|
2
|
+
|
|
3
|
+
On Android, Python's ssl module cannot reach the system certificate store,
|
|
4
|
+
so httpx (which uses certifi or the default SSL context) fails TLS
|
|
5
|
+
handshakes. This module detects Android at import time and builds an
|
|
6
|
+
AsyncClient that disables certificate verification there, while keeping it
|
|
7
|
+
enabled on every other platform.
|
|
8
|
+
|
|
9
|
+
Every module that needs an HTTP client should call ``get_client()`` (or
|
|
10
|
+
``get_client_with_headers()`` for per-source custom headers) instead of
|
|
11
|
+
creating its own ``httpx.AsyncClient``.
|
|
12
|
+
|
|
13
|
+
This module also owns the two helpers every source shares: HTML parsing
|
|
14
|
+
(``parse_html``/``fetch_soup``) and user-facing error wording
|
|
15
|
+
(``describe_error``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import ssl
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from bs4 import BeautifulSoup
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_android() -> bool:
|
|
29
|
+
try:
|
|
30
|
+
from kivy.utils import platform
|
|
31
|
+
|
|
32
|
+
return bool(platform == "android")
|
|
33
|
+
except Exception:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ANDROID = _is_android()
|
|
38
|
+
|
|
39
|
+
_DEFAULT_HEADERS = {
|
|
40
|
+
"User-Agent": (
|
|
41
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
42
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
43
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
44
|
+
),
|
|
45
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
46
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_ssl_context() -> ssl.SSLContext | None:
|
|
51
|
+
"""Return an SSL context suitable for the current platform.
|
|
52
|
+
|
|
53
|
+
On Android the system CA bundle is not reachable from Python, so we
|
|
54
|
+
create a permissive context that skips certificate verification.
|
|
55
|
+
On desktop platforms we return None so httpx uses its own default
|
|
56
|
+
(which verifies certificates via certifi).
|
|
57
|
+
"""
|
|
58
|
+
if not _ANDROID:
|
|
59
|
+
return None
|
|
60
|
+
ctx = ssl.create_default_context()
|
|
61
|
+
ctx.check_hostname = False
|
|
62
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
63
|
+
return ctx
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_ssl_ctx: ssl.SSLContext | None = _make_ssl_context()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_client(
|
|
70
|
+
*,
|
|
71
|
+
headers: dict[str, str] | None = None,
|
|
72
|
+
timeout: int = 30,
|
|
73
|
+
follow_redirects: bool = True,
|
|
74
|
+
) -> httpx.AsyncClient:
|
|
75
|
+
"""Create an ``httpx.AsyncClient`` with Android-aware SSL settings.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
headers:
|
|
80
|
+
Extra headers merged on top of the sensible defaults.
|
|
81
|
+
timeout:
|
|
82
|
+
Request timeout in seconds.
|
|
83
|
+
follow_redirects:
|
|
84
|
+
Whether to follow HTTP redirects.
|
|
85
|
+
"""
|
|
86
|
+
merged = dict(_DEFAULT_HEADERS)
|
|
87
|
+
if headers:
|
|
88
|
+
merged.update(headers)
|
|
89
|
+
return httpx.AsyncClient(
|
|
90
|
+
headers=merged,
|
|
91
|
+
follow_redirects=follow_redirects,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
# On Android the system CA store is unreachable; disable
|
|
94
|
+
# verification so TLS handshakes succeed. On desktop the
|
|
95
|
+
# ssl_ctx is None and httpx verifies via certifi as usual.
|
|
96
|
+
verify=_ssl_ctx if _ssl_ctx is not None else True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_client_with_headers(
|
|
101
|
+
headers: dict[str, str],
|
|
102
|
+
*,
|
|
103
|
+
timeout: int = 30,
|
|
104
|
+
follow_redirects: bool = True,
|
|
105
|
+
) -> httpx.AsyncClient:
|
|
106
|
+
"""Convenience wrapper for sources that supply their own headers.
|
|
107
|
+
|
|
108
|
+
The provided *headers* override the defaults (e.g. a custom Referer).
|
|
109
|
+
"""
|
|
110
|
+
return get_client(
|
|
111
|
+
headers=headers, timeout=timeout, follow_redirects=follow_redirects
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
# HTML parsing
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _detect_parser() -> str:
|
|
121
|
+
"""Return the fastest bs4 tree builder available on this machine.
|
|
122
|
+
|
|
123
|
+
lxml parses roughly 1.6x faster than the pure-Python html.parser and
|
|
124
|
+
releases the GIL while doing so, but it is a compiled extension that the
|
|
125
|
+
Android APK does not bundle (see buildozer.spec). Probe bs4 itself
|
|
126
|
+
rather than ``import lxml`` so a broken native library also falls back
|
|
127
|
+
instead of crashing on the first fetch.
|
|
128
|
+
"""
|
|
129
|
+
for name in ("lxml", "html.parser"):
|
|
130
|
+
try:
|
|
131
|
+
BeautifulSoup("", name)
|
|
132
|
+
except Exception:
|
|
133
|
+
continue
|
|
134
|
+
return name
|
|
135
|
+
return "html.parser"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
HTML_PARSER: str = _detect_parser()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def parse_html(markup: str) -> BeautifulSoup:
|
|
142
|
+
"""Parse HTML off the event loop and return a ``BeautifulSoup``."""
|
|
143
|
+
return await asyncio.to_thread(BeautifulSoup, markup, HTML_PARSER)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Transient statuses worth retrying. 404/403 and friends are deliberately
|
|
147
|
+
# absent: retrying a removed novel or an anti-bot block only wastes the
|
|
148
|
+
# user's time and makes the rate limiting worse.
|
|
149
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
150
|
+
_MAX_ATTEMPTS = 3
|
|
151
|
+
# Cap on a server-supplied Retry-After. The GUI wraps most of these calls in
|
|
152
|
+
# async_loop.run(..., timeout=30), so a large value would eat the whole budget
|
|
153
|
+
# and surface as a timeout instead of a rate-limit message.
|
|
154
|
+
_MAX_RETRY_AFTER = 8.0
|
|
155
|
+
# Used when the server sends no usable Retry-After.
|
|
156
|
+
_RETRY_BACKOFF = (1.0, 2.0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _sleep(seconds: float) -> None:
|
|
160
|
+
"""Pause between retries. Tests monkeypatch this to stay instant."""
|
|
161
|
+
await asyncio.sleep(seconds)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
165
|
+
"""Seconds to wait before retrying *response* on attempt *attempt*.
|
|
166
|
+
|
|
167
|
+
A sane server-supplied ``Retry-After`` wins; anything larger is clamped to
|
|
168
|
+
the cap rather than honoured, and a missing or non-numeric one falls back
|
|
169
|
+
to exponential backoff.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
seconds = float(response.headers.get("Retry-After", ""))
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
seconds = 0.0
|
|
175
|
+
if seconds > 0:
|
|
176
|
+
return min(seconds, _MAX_RETRY_AFTER)
|
|
177
|
+
index = attempt - 1
|
|
178
|
+
return _RETRY_BACKOFF[index] if index < len(_RETRY_BACKOFF) else _RETRY_BACKOFF[-1]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _get_with_retry(
|
|
182
|
+
client: httpx.AsyncClient,
|
|
183
|
+
url: str,
|
|
184
|
+
*,
|
|
185
|
+
params: dict[str, Any] | None = None,
|
|
186
|
+
headers: dict[str, str] | None = None,
|
|
187
|
+
) -> httpx.Response:
|
|
188
|
+
"""GET *url*, retrying transient statuses, and return the response.
|
|
189
|
+
|
|
190
|
+
Raises via ``response.raise_for_status()`` for whatever is still non-2xx
|
|
191
|
+
once the attempts are spent.
|
|
192
|
+
"""
|
|
193
|
+
response = await client.get(url, params=params, headers=headers)
|
|
194
|
+
for attempt in range(1, _MAX_ATTEMPTS):
|
|
195
|
+
if response.status_code not in _RETRY_STATUSES:
|
|
196
|
+
break
|
|
197
|
+
await _sleep(_retry_delay(response, attempt))
|
|
198
|
+
response = await client.get(url, params=params, headers=headers)
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def fetch_soup(
|
|
204
|
+
client: httpx.AsyncClient,
|
|
205
|
+
url: str,
|
|
206
|
+
*,
|
|
207
|
+
params: dict[str, Any] | None = None,
|
|
208
|
+
headers: dict[str, str] | None = None,
|
|
209
|
+
) -> BeautifulSoup:
|
|
210
|
+
"""GET *url*, raise on a non-2xx status, and parse the body off-loop.
|
|
211
|
+
|
|
212
|
+
Transient statuses are retried with backoff: novelfire rate-limits bursty
|
|
213
|
+
chapter paging, and without this every 429/500 became a dead end for the
|
|
214
|
+
caller. Non-transient failures still raise on the first attempt.
|
|
215
|
+
"""
|
|
216
|
+
response = await _get_with_retry(client, url, params=params, headers=headers)
|
|
217
|
+
return await parse_html(response.text)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------
|
|
221
|
+
# User-facing error wording
|
|
222
|
+
# --------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _status_code(error: BaseException) -> int | None:
|
|
226
|
+
"""Best-effort status code from an HTTP exception.
|
|
227
|
+
|
|
228
|
+
Works for ``httpx.HTTPStatusError`` and, because it reads ``.response``
|
|
229
|
+
structurally, for ``curl_cffi.requests.exceptions.HTTPError`` too — so
|
|
230
|
+
this module never has to import curl_cffi (which Android lacks).
|
|
231
|
+
"""
|
|
232
|
+
response = getattr(error, "response", None)
|
|
233
|
+
code = getattr(response, "status_code", None)
|
|
234
|
+
return code if isinstance(code, int) else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def describe_error(error: BaseException, action: str) -> str:
|
|
238
|
+
"""Turn an exception into a short user-facing message.
|
|
239
|
+
|
|
240
|
+
*action* is what the caller was doing, e.g. ``"Search failed"`` or
|
|
241
|
+
``"Failed to fetch chapters"``. Only genuine network failures are
|
|
242
|
+
described as a connection problem; anything else is named instead, so
|
|
243
|
+
a bug in the app never masquerades as "check your connection".
|
|
244
|
+
"""
|
|
245
|
+
status = _status_code(error)
|
|
246
|
+
if status is not None:
|
|
247
|
+
if status == 404:
|
|
248
|
+
detail = "page not found (404)"
|
|
249
|
+
elif status == 403:
|
|
250
|
+
detail = "access denied (403)"
|
|
251
|
+
elif status == 429:
|
|
252
|
+
detail = "rate limited (429) — wait a moment and retry"
|
|
253
|
+
elif 500 <= status < 600:
|
|
254
|
+
detail = f"server error ({status}) — try again later"
|
|
255
|
+
else:
|
|
256
|
+
detail = f"HTTP {status}"
|
|
257
|
+
return f"{action} — {detail}."
|
|
258
|
+
if isinstance(error, httpx.TimeoutException):
|
|
259
|
+
return f"{action} — timed out. Check your connection."
|
|
260
|
+
if isinstance(error, httpx.TransportError):
|
|
261
|
+
return f"{action} — could not reach the server."
|
|
262
|
+
return f"{action} — unexpected error ({type(error).__name__})."
|