novel-downloader 1.4.0__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.
Files changed (31) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +69 -10
  3. novel_downloader/config/adapter.py +42 -9
  4. novel_downloader/core/downloaders/base.py +26 -22
  5. novel_downloader/core/downloaders/common.py +41 -5
  6. novel_downloader/core/downloaders/qidian.py +60 -32
  7. novel_downloader/core/exporters/common/epub.py +153 -68
  8. novel_downloader/core/exporters/epub_util.py +1358 -0
  9. novel_downloader/core/exporters/linovelib/epub.py +147 -190
  10. novel_downloader/core/fetchers/linovelib/browser.py +15 -0
  11. novel_downloader/core/fetchers/linovelib/session.py +15 -0
  12. novel_downloader/core/fetchers/qidian/browser.py +62 -10
  13. novel_downloader/core/interfaces/downloader.py +13 -12
  14. novel_downloader/locales/en.json +2 -0
  15. novel_downloader/locales/zh.json +2 -0
  16. novel_downloader/models/__init__.py +2 -0
  17. novel_downloader/models/config.py +8 -0
  18. novel_downloader/tui/screens/home.py +5 -4
  19. novel_downloader/utils/constants.py +0 -29
  20. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/METADATA +4 -2
  21. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/RECORD +25 -30
  22. novel_downloader/core/exporters/epub_utils/__init__.py +0 -40
  23. novel_downloader/core/exporters/epub_utils/css_builder.py +0 -75
  24. novel_downloader/core/exporters/epub_utils/image_loader.py +0 -131
  25. novel_downloader/core/exporters/epub_utils/initializer.py +0 -100
  26. novel_downloader/core/exporters/epub_utils/text_to_html.py +0 -178
  27. novel_downloader/core/exporters/epub_utils/volume_intro.py +0 -60
  28. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/WHEEL +0 -0
  29. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/entry_points.txt +0 -0
  30. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/licenses/LICENSE +0 -0
  31. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ novel_downloader
6
6
  Core package for the Novel Downloader project.
7
7
  """
8
8
 
9
- __version__ = "1.4.0"
9
+ __version__ = "1.4.2"
10
10
 
11
11
  __author__ = "Saudade Z"
12
12
  __email__ = "saudadez217@gmail.com"
@@ -9,6 +9,7 @@ Download novels from supported sites via CLI.
9
9
  import asyncio
10
10
  import getpass
11
11
  from argparse import Namespace, _SubParsersAction
12
+ from collections.abc import Iterable
12
13
  from dataclasses import asdict
13
14
  from pathlib import Path
14
15
  from typing import Any
@@ -21,7 +22,7 @@ from novel_downloader.core.factory import (
21
22
  get_parser,
22
23
  )
23
24
  from novel_downloader.core.interfaces import FetcherProtocol
24
- from novel_downloader.models import LoginField
25
+ from novel_downloader.models import BookConfig, LoginField
25
26
  from novel_downloader.utils.cookies import resolve_cookies
26
27
  from novel_downloader.utils.i18n import t
27
28
  from novel_downloader.utils.logger import setup_logging
@@ -36,6 +37,9 @@ def register_download_subcommand(subparsers: _SubParsersAction) -> None: # type
36
37
  )
37
38
  parser.add_argument("--config", type=str, help=t("help_config"))
38
39
 
40
+ parser.add_argument("--start", type=str, help=t("download_option_start"))
41
+ parser.add_argument("--end", type=str, help=t("download_option_end"))
42
+
39
43
  parser.set_defaults(func=handle_download)
40
44
 
41
45
 
@@ -44,7 +48,11 @@ def handle_download(args: Namespace) -> None:
44
48
 
45
49
  site: str = args.site
46
50
  config_path: Path | None = Path(args.config) if args.config else None
47
- book_ids: list[str] = args.book_ids or []
51
+ book_ids: list[BookConfig] = _cli_args_to_book_configs(
52
+ args.book_ids,
53
+ args.start,
54
+ args.end,
55
+ )
48
56
 
49
57
  print(t("download_site_info", site=site))
50
58
 
@@ -63,25 +71,73 @@ def handle_download(args: Namespace) -> None:
63
71
  print(t("download_fail_get_ids", err=str(e)))
64
72
  return
65
73
 
66
- invalid_ids = {"0000000000"}
67
- valid_book_ids = set(book_ids) - invalid_ids
74
+ valid_books = _filter_valid_book_configs(book_ids)
68
75
 
69
76
  if not book_ids:
70
77
  print(t("download_no_ids"))
71
78
  return
72
79
 
73
- if not valid_book_ids:
80
+ if not valid_books:
74
81
  print(t("download_only_example", example="0000000000"))
75
82
  print(t("download_edit_config"))
76
83
  return
77
84
 
78
- asyncio.run(_download(adapter, site, valid_book_ids))
85
+ asyncio.run(_download(adapter, site, valid_books))
86
+
87
+
88
+ def _cli_args_to_book_configs(
89
+ book_ids: list[str],
90
+ start_id: str | None,
91
+ end_id: str | None,
92
+ ) -> list[BookConfig]:
93
+ """
94
+ Convert CLI book_ids and optional --start/--end into a list of BookConfig.
95
+ Only the first book_id uses start/end; others are minimal.
96
+ """
97
+ if not book_ids:
98
+ return []
99
+
100
+ result: list[BookConfig] = []
101
+
102
+ first: BookConfig = {"book_id": book_ids[0]}
103
+ if start_id:
104
+ first["start_id"] = start_id
105
+ if end_id:
106
+ first["end_id"] = end_id
107
+ result.append(first)
108
+
109
+ for book_id in book_ids[1:]:
110
+ result.append({"book_id": book_id})
111
+
112
+ return result
113
+
114
+
115
+ def _filter_valid_book_configs(
116
+ books: list[BookConfig],
117
+ invalid_ids: Iterable[str] = ("0000000000",),
118
+ ) -> list[BookConfig]:
119
+ """
120
+ Filter a list of BookConfig:
121
+ - Removes entries with invalid or placeholder book_ids
122
+ - Deduplicates based on book_id while preserving order
123
+ """
124
+ seen = set(invalid_ids)
125
+ result: list[BookConfig] = []
126
+
127
+ for book in books:
128
+ book_id = book["book_id"]
129
+ if book_id in seen:
130
+ continue
131
+ seen.add(book_id)
132
+ result.append(book)
133
+
134
+ return result
79
135
 
80
136
 
81
137
  async def _download(
82
138
  adapter: ConfigAdapter,
83
139
  site: str,
84
- valid_book_ids: set[str],
140
+ valid_books: list[BookConfig],
85
141
  ) -> None:
86
142
  downloader_cfg = adapter.get_downloader_config()
87
143
  fetcher_cfg = adapter.get_fetcher_config()
@@ -110,9 +166,12 @@ async def _download(
110
166
  config=downloader_cfg,
111
167
  )
112
168
 
113
- for book_id in valid_book_ids:
114
- print(t("download_downloading", book_id=book_id, site=site))
115
- await downloader.download(book_id, progress_hook=_print_progress)
169
+ for book in valid_books:
170
+ print(t("download_downloading", book_id=book["book_id"], site=site))
171
+ await downloader.download(
172
+ book,
173
+ progress_hook=_print_progress,
174
+ )
116
175
 
117
176
  if downloader_cfg.login_required and fetcher.is_logged_in:
118
177
  await fetcher.save_state()
@@ -10,6 +10,7 @@ site name into structured dataclass-based config models.
10
10
  from typing import Any
11
11
 
12
12
  from novel_downloader.models import (
13
+ BookConfig,
13
14
  DownloaderConfig,
14
15
  ExporterConfig,
15
16
  FetcherConfig,
@@ -169,22 +170,54 @@ class ConfigAdapter:
169
170
  split_mode=site_cfg.get("split_mode", "book"),
170
171
  )
171
172
 
172
- def get_book_ids(self) -> list[str]:
173
+ def get_book_ids(self) -> list[BookConfig]:
173
174
  """
174
175
  从 config["sites"][site]["book_ids"] 中提取目标书籍列表
175
176
  """
176
177
  site_cfg = self._get_site_cfg()
177
- raw_ids = site_cfg.get("book_ids", [])
178
+ raw = site_cfg.get("book_ids", [])
178
179
 
179
- if isinstance(raw_ids, str):
180
- return [raw_ids]
180
+ if isinstance(raw, str | int):
181
+ return [{"book_id": str(raw)}]
181
182
 
182
- if isinstance(raw_ids, int):
183
- return [str(raw_ids)]
183
+ if isinstance(raw, dict):
184
+ return [self._dict_to_book_config(raw)]
184
185
 
185
- if not isinstance(raw_ids, list):
186
+ if not isinstance(raw, list):
186
187
  raise ValueError(
187
- f"book_ids must be a list or string, got {type(raw_ids).__name__}"
188
+ f"book_ids must be a list or string, got {type(raw).__name__}"
188
189
  )
189
190
 
190
- return [str(book_id) for book_id in raw_ids]
191
+ result: list[BookConfig] = []
192
+ for item in raw:
193
+ try:
194
+ if isinstance(item, str | int):
195
+ result.append({"book_id": str(item)})
196
+ elif isinstance(item, dict):
197
+ result.append(self._dict_to_book_config(item))
198
+ except ValueError:
199
+ continue
200
+
201
+ return result
202
+
203
+ @staticmethod
204
+ def _dict_to_book_config(data: dict[str, Any]) -> BookConfig:
205
+ """
206
+ Converts a dict to BookConfig with type normalization.
207
+ Raises ValueError if 'book_id' is missing.
208
+ """
209
+ if "book_id" not in data:
210
+ raise ValueError("Missing required field 'book_id'")
211
+
212
+ result: BookConfig = {"book_id": str(data["book_id"])}
213
+
214
+ if "start_id" in data:
215
+ result["start_id"] = str(data["start_id"])
216
+
217
+ if "end_id" in data:
218
+ result["end_id"] = str(data["end_id"])
219
+
220
+ if "ignore_ids" in data:
221
+ result["ignore_ids"] = [str(x) for x in data["ignore_ids"]]
222
+
223
+ return result
@@ -19,7 +19,7 @@ from novel_downloader.core.interfaces import (
19
19
  FetcherProtocol,
20
20
  ParserProtocol,
21
21
  )
22
- from novel_downloader.models import DownloaderConfig
22
+ from novel_downloader.models import BookConfig, DownloaderConfig
23
23
 
24
24
 
25
25
  class BaseDownloader(DownloaderProtocol, abc.ABC):
@@ -53,7 +53,7 @@ class BaseDownloader(DownloaderProtocol, abc.ABC):
53
53
 
54
54
  async def download_many(
55
55
  self,
56
- book_ids: list[str],
56
+ books: list[BookConfig],
57
57
  *,
58
58
  progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
59
59
  **kwargs: Any,
@@ -61,33 +61,34 @@ class BaseDownloader(DownloaderProtocol, abc.ABC):
61
61
  """
62
62
  Download multiple books with pre-download hook and error handling.
63
63
 
64
- :param book_ids: A list of book identifiers to download.
65
- :param progress_hook: (optional) Called after each chapter;
64
+ :param books: List of BookConfig entries.
65
+ :param progress_hook: Optional async callback after each chapter.
66
66
  args: completed_count, total_count.
67
67
  """
68
68
  if not await self._ensure_ready():
69
+ book_ids = [b["book_id"] for b in books]
69
70
  self.logger.warning(
70
- "[%s] login failed, skipping download of %s",
71
+ "[%s] login failed, skipping download of books: %s",
71
72
  self._site,
72
- book_ids,
73
+ ", ".join(book_ids) or "<none>",
73
74
  )
74
75
  return
75
76
 
76
- for book_id in book_ids:
77
+ for book in books:
77
78
  try:
78
79
  await self._download_one(
79
- book_id,
80
+ book,
80
81
  progress_hook=progress_hook,
81
82
  **kwargs,
82
83
  )
83
84
  except Exception as e:
84
- self._handle_download_exception(book_id, e)
85
+ self._handle_download_exception(book, e)
85
86
 
86
87
  await self._finalize()
87
88
 
88
89
  async def download(
89
90
  self,
90
- book_id: str,
91
+ book: BookConfig,
91
92
  *,
92
93
  progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
93
94
  **kwargs: Any,
@@ -95,33 +96,34 @@ class BaseDownloader(DownloaderProtocol, abc.ABC):
95
96
  """
96
97
  Download a single book with pre-download hook and error handling.
97
98
 
98
- :param book_id: The identifier of the book to download.
99
- :param progress_hook: (optional) Called after each chapter;
99
+ :param book: BookConfig with at least 'book_id'.
100
+ :param progress_hook: Optional async callback after each chapter.
100
101
  args: completed_count, total_count.
101
102
  """
102
103
  if not await self._ensure_ready():
103
104
  self.logger.warning(
104
- "[%s] login failed, skipping download of %s",
105
+ "[%s] login failed, skipping download of book: %s (%s-%s)",
105
106
  self._site,
106
- book_id,
107
+ book["book_id"],
108
+ book.get("start_id", "-"),
109
+ book.get("end_id", "-"),
107
110
  )
108
- return
109
111
 
110
112
  try:
111
113
  await self._download_one(
112
- book_id,
114
+ book,
113
115
  progress_hook=progress_hook,
114
116
  **kwargs,
115
117
  )
116
118
  except Exception as e:
117
- self._handle_download_exception(book_id, e)
119
+ self._handle_download_exception(book, e)
118
120
 
119
121
  await self._finalize()
120
122
 
121
123
  @abc.abstractmethod
122
124
  async def _download_one(
123
125
  self,
124
- book_id: str,
126
+ book: BookConfig,
125
127
  *,
126
128
  progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
127
129
  **kwargs: Any,
@@ -208,19 +210,21 @@ class BaseDownloader(DownloaderProtocol, abc.ABC):
208
210
  def download_workers(self) -> int:
209
211
  return self._config.download_workers
210
212
 
211
- def _handle_download_exception(self, book_id: str, error: Exception) -> None:
213
+ def _handle_download_exception(self, book: BookConfig, error: Exception) -> None:
212
214
  """
213
215
  Handle download errors in a consistent way.
214
216
 
215
217
  This method can be overridden or extended to implement retry logic, etc.
216
218
 
217
- :param book_id: The ID of the book that failed.
219
+ :param book: The book that failed.
218
220
  :param error: The exception raised during download.
219
221
  """
220
222
  self.logger.warning(
221
- "[%s] Failed to download %r: %s",
223
+ "[%s] Failed to download (book_id=%s, start=%s, end=%s): %s",
222
224
  self.__class__.__name__,
223
- book_id,
225
+ book.get("book_id", "<unknown>"),
226
+ book.get("start_id", "-"),
227
+ book.get("end_id", "-"),
224
228
  error,
225
229
  )
226
230
 
@@ -12,7 +12,13 @@ from contextlib import suppress
12
12
  from typing import Any, cast
13
13
 
14
14
  from novel_downloader.core.downloaders.base import BaseDownloader
15
- from novel_downloader.models import ChapterDict, CidTask, HtmlTask, RestoreTask
15
+ from novel_downloader.models import (
16
+ BookConfig,
17
+ ChapterDict,
18
+ CidTask,
19
+ HtmlTask,
20
+ RestoreTask,
21
+ )
16
22
  from novel_downloader.utils.chapter_storage import ChapterStorage
17
23
  from novel_downloader.utils.file_utils import save_as_json, save_as_txt
18
24
  from novel_downloader.utils.time_utils import (
@@ -28,7 +34,7 @@ class CommonDownloader(BaseDownloader):
28
34
 
29
35
  async def _download_one(
30
36
  self,
31
- book_id: str,
37
+ book: BookConfig,
32
38
  *,
33
39
  progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
34
40
  **kwargs: Any,
@@ -36,9 +42,13 @@ class CommonDownloader(BaseDownloader):
36
42
  """
37
43
  The full download logic for a single book.
38
44
 
39
- :param book_id: The identifier of the book to download.
45
+ :param book: BookConfig with at least 'book_id'.
40
46
  """
41
47
  TAG = "[Downloader]"
48
+ book_id = book["book_id"]
49
+ start_id = book.get("start_id")
50
+ end_id = book.get("end_id")
51
+ ignore_set = set(book.get("ignore_ids", []))
42
52
 
43
53
  raw_base = self.raw_data_dir / book_id
44
54
  cache_base = self.cache_dir / book_id
@@ -145,6 +155,10 @@ class CommonDownloader(BaseDownloader):
145
155
  cid_queue.task_done()
146
156
  continue
147
157
 
158
+ if cid in ignore_set:
159
+ cid_queue.task_done()
160
+ continue
161
+
148
162
  try:
149
163
  async with semaphore:
150
164
  html_list = await self.fetcher.get_book_chapter(book_id, cid)
@@ -369,15 +383,33 @@ class CommonDownloader(BaseDownloader):
369
383
  )
370
384
  )
371
385
 
386
+ found_start = start_id is None
387
+ stop_early = False
372
388
  last_cid: str | None = None
389
+
373
390
  for vol_idx, vol in enumerate(vols):
374
391
  chapters = vol.get("chapters", [])
375
392
  for chap_idx, chap in enumerate(chapters):
393
+ if stop_early:
394
+ break
395
+
376
396
  cid = chap.get("chapterId")
397
+
398
+ # Skip until reaching start_id
399
+ if not found_start:
400
+ if cid == start_id:
401
+ found_start = True
402
+ else:
403
+ completed_count += 1
404
+ last_cid = cid
405
+ continue
406
+
407
+ # Stop when reaching end_id
408
+ if end_id is not None and cid == end_id:
409
+ stop_early = True
410
+
377
411
  if cid and normal_cs.exists(cid) and self.skip_existing:
378
412
  completed_count += 1
379
- if progress_hook:
380
- await progress_hook(completed_count, total_chapters)
381
413
  last_cid = cid
382
414
  continue
383
415
 
@@ -389,8 +421,12 @@ class CommonDownloader(BaseDownloader):
389
421
  prev_cid=last_cid,
390
422
  )
391
423
  )
424
+
392
425
  last_cid = cid
393
426
 
427
+ if stop_early:
428
+ break
429
+
394
430
  await restore_queue.join()
395
431
  await cid_queue.join()
396
432
  await html_queue.join()
@@ -18,6 +18,7 @@ from novel_downloader.core.interfaces import (
18
18
  ParserProtocol,
19
19
  )
20
20
  from novel_downloader.models import (
21
+ BookConfig,
21
22
  ChapterDict,
22
23
  CidTask,
23
24
  DownloaderConfig,
@@ -48,7 +49,7 @@ class QidianDownloader(BaseDownloader):
48
49
 
49
50
  async def _download_one(
50
51
  self,
51
- book_id: str,
52
+ book: BookConfig,
52
53
  *,
53
54
  progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
54
55
  **kwargs: Any,
@@ -56,9 +57,13 @@ class QidianDownloader(BaseDownloader):
56
57
  """
57
58
  The full download logic for a single book.
58
59
 
59
- :param book_id: The identifier of the book to download.
60
+ :param book: BookConfig with at least 'book_id'.
60
61
  """
61
62
  TAG = "[Downloader]"
63
+ book_id = book["book_id"]
64
+ start_id = book.get("start_id")
65
+ end_id = book.get("end_id")
66
+ ignore_set = set(book.get("ignore_ids", []))
62
67
 
63
68
  raw_base = self.raw_data_dir / book_id
64
69
  cache_base = self.cache_dir / book_id
@@ -140,6 +145,10 @@ class QidianDownloader(BaseDownloader):
140
145
  cid_queue.task_done()
141
146
  continue
142
147
 
148
+ if cid in ignore_set:
149
+ cid_queue.task_done()
150
+ continue
151
+
143
152
  try:
144
153
  html_list = await self.fetcher.get_book_chapter(book_id, cid)
145
154
  await html_queue.put(
@@ -194,40 +203,39 @@ class QidianDownloader(BaseDownloader):
194
203
  skip_retry = False
195
204
  try:
196
205
  chap_json: ChapterDict | None = None
197
- if self.is_restricted_page(task.html_list):
206
+ if self.check_restricted(task.html_list):
198
207
  self.logger.info(
199
208
  "[Parser] Skipped restricted page for cid %s", task.cid
200
209
  )
201
210
  skip_retry = True
202
- else:
203
- chap_json = await asyncio.to_thread(
204
- self.parser.parse_chapter,
205
- task.html_list,
211
+ raise ValueError("Restricted content detected")
212
+
213
+ is_encrypted = self.check_encrypted(task.html_list)
214
+ chap_json = await asyncio.to_thread(
215
+ self.parser.parse_chapter,
216
+ task.html_list,
217
+ task.cid,
218
+ )
219
+ if is_encrypted:
220
+ skip_retry = True
221
+ if self.save_html:
222
+ folder = chapters_html_dir / (
223
+ "html_encrypted" if is_encrypted else "html_plain"
224
+ )
225
+ html_path = folder / f"{task.cid}.html"
226
+ save_as_txt(task.html_list[0], html_path, on_exist="skip")
227
+ self.logger.debug(
228
+ "%s Saved raw HTML for chapter %s to %s",
229
+ TAG,
206
230
  task.cid,
231
+ html_path,
207
232
  )
208
- if self.check_encrypted(task.html_list):
209
- skip_retry = True
210
233
  if chap_json:
211
234
  await save_queue.put(chap_json)
212
235
  self.logger.info(
213
236
  "[Parser] saved chapter %s",
214
237
  task.cid,
215
238
  )
216
- if self.save_html:
217
- is_encrypted = chap_json.get("extra", {}).get(
218
- "encrypted", False
219
- )
220
- folder = chapters_html_dir / (
221
- "html_encrypted" if is_encrypted else "html_plain"
222
- )
223
- html_path = folder / f"{task.cid}.html"
224
- save_as_txt(task.html_list[0], html_path, on_exist="skip")
225
- self.logger.debug(
226
- "%s Saved raw HTML for chapter %s to %s",
227
- TAG,
228
- task.cid,
229
- html_path,
230
- )
231
239
  else:
232
240
  raise ValueError("Empty parse result")
233
241
  except Exception as e:
@@ -296,20 +304,40 @@ class QidianDownloader(BaseDownloader):
296
304
  )
297
305
  )
298
306
 
299
- last_cid: str | None = None
307
+ found_start = start_id is None
308
+ stop_early = False
309
+
300
310
  for vol in book_info.get("volumes", []):
301
311
  chapters = vol.get("chapters", [])
302
312
  for chap in chapters:
313
+ if stop_early:
314
+ break
315
+
303
316
  cid = chap.get("chapterId")
304
- if cid and normal_cs.exists(cid) and self.skip_existing:
317
+ if not cid:
318
+ continue
319
+
320
+ if not found_start:
321
+ if cid == start_id:
322
+ found_start = True
323
+ else:
324
+ completed_count += 1
325
+ continue
326
+
327
+ if end_id is not None and cid == end_id:
328
+ stop_early = True
329
+
330
+ if cid in ignore_set:
331
+ continue
332
+
333
+ if normal_cs.exists(cid) and self.skip_existing:
305
334
  completed_count += 1
306
- if progress_hook:
307
- await progress_hook(completed_count, total_chapters)
308
- last_cid = cid
309
335
  continue
310
336
 
311
- await cid_queue.put(CidTask(cid=cid, prev_cid=last_cid))
312
- last_cid = cid
337
+ await cid_queue.put(CidTask(cid=cid, prev_cid=None))
338
+
339
+ if stop_early:
340
+ break
313
341
 
314
342
  await cid_queue.join()
315
343
  await html_queue.join()
@@ -333,7 +361,7 @@ class QidianDownloader(BaseDownloader):
333
361
  return
334
362
 
335
363
  @staticmethod
336
- def is_restricted_page(html_list: list[str]) -> bool:
364
+ def check_restricted(html_list: list[str]) -> bool:
337
365
  """
338
366
  Return True if page content indicates access restriction
339
367
  (e.g. not subscribed/purchased).