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.
Files changed (68) hide show
  1. core/__init__.py +5 -0
  2. core/downloader.py +168 -0
  3. core/epub.py +143 -0
  4. core/http_client.py +262 -0
  5. core/library.py +423 -0
  6. core/paths.py +69 -0
  7. core/progress.py +201 -0
  8. core/translation.py +93 -0
  9. core/utils.py +83 -0
  10. gui/__init__.py +1 -0
  11. gui/async_runner.py +100 -0
  12. gui/data/NotoNaskhArabic.ttf +0 -0
  13. gui/data/icon.png +0 -0
  14. gui/kv/chapter_list.kv +86 -0
  15. gui/kv/download_dialog.kv +47 -0
  16. gui/kv/download_picker.kv +62 -0
  17. gui/kv/history.kv +50 -0
  18. gui/kv/home_tab.kv +208 -0
  19. gui/kv/novel_list.kv +44 -0
  20. gui/kv/reader.kv +71 -0
  21. gui/kv/search_tab.kv +121 -0
  22. gui/kv/settings_tab.kv +126 -0
  23. gui/kv/topbar.kv +24 -0
  24. gui/kv/update.kv +60 -0
  25. gui/main.py +154 -0
  26. gui/novelfetch.kv +11 -0
  27. gui/screens/__init__.py +1 -0
  28. gui/screens/app_settings.py +82 -0
  29. gui/screens/browse.py +162 -0
  30. gui/screens/chapter_list.py +540 -0
  31. gui/screens/download_dialog.py +133 -0
  32. gui/screens/download_picker.py +195 -0
  33. gui/screens/history.py +212 -0
  34. gui/screens/home_tab.py +1593 -0
  35. gui/screens/main_screen.py +134 -0
  36. gui/screens/novel_list.py +43 -0
  37. gui/screens/reader.py +660 -0
  38. gui/screens/search_tab.py +278 -0
  39. gui/screens/settings_tab.py +345 -0
  40. gui/screens/source_picker.py +73 -0
  41. gui/screens/theme.py +63 -0
  42. gui/screens/topbar.py +72 -0
  43. gui/screens/update.py +490 -0
  44. gui/screens/utils.py +811 -0
  45. novelfetch-2.0.9.dist-info/METADATA +256 -0
  46. novelfetch-2.0.9.dist-info/RECORD +68 -0
  47. novelfetch-2.0.9.dist-info/WHEEL +5 -0
  48. novelfetch-2.0.9.dist-info/entry_points.txt +3 -0
  49. novelfetch-2.0.9.dist-info/licenses/LICENSE +21 -0
  50. novelfetch-2.0.9.dist-info/top_level.txt +4 -0
  51. sources/__init__.py +14 -0
  52. sources/base.py +93 -0
  53. sources/novelfire.py +266 -0
  54. sources/novelphoenix.py +295 -0
  55. sources/royalroad.py +158 -0
  56. sources/scriblehub.py +264 -0
  57. sources/wuxiaspot.py +328 -0
  58. tui/__init__.py +12 -0
  59. tui/browse.py +340 -0
  60. tui/download.py +447 -0
  61. tui/library.py +223 -0
  62. tui/main.py +46 -0
  63. tui/main_menu.py +123 -0
  64. tui/novelfetch.tcss +128 -0
  65. tui/reader.py +264 -0
  66. tui/shared.py +112 -0
  67. tui/source_picker.py +37 -0
  68. tui/utils.py +3 -0
core/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Core shared framework used by both the TUI and GUI frontends.
2
+
3
+ Hosts logic that is independent of any particular UI: progress tracking,
4
+ translation, EPUB export, and source dispatch helpers.
5
+ """
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__})."