eaf_base_api 3.2.4__tar.gz → 3.3.1__tar.gz
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.
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/PKG-INFO +3 -3
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/base.py +102 -44
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/pyproject.toml +3 -3
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/LICENSE +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/README.md +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/__init__.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/config.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/errors.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/logger.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/static_functions.py +0 -0
- {eaf_base_api-3.2.4 → eaf_base_api-3.3.1}/base_api/modules/type_hints.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eaf_base_api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.1
|
|
4
4
|
Summary: A base API for EchterAlsFake's Porn APIs
|
|
5
5
|
Author: Johannes Habel
|
|
6
6
|
Author-email: Johannes Habel <EchterAlsFake@proton.me>
|
|
@@ -10,8 +10,8 @@ Classifier: Programming Language :: Python
|
|
|
10
10
|
Requires-Dist: curl-cffi
|
|
11
11
|
Requires-Dist: tenacity>=9.1.2
|
|
12
12
|
Requires-Dist: m3u8 ; extra == 'hls'
|
|
13
|
-
Requires-Dist: av ; python_full_version >= '3.
|
|
14
|
-
Requires-Python: >=3.
|
|
13
|
+
Requires-Dist: av ; python_full_version >= '3.12' and extra == 'hls'
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
15
|
Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
|
|
16
16
|
Project-URL: Repository, https://github.com/EchterAlsFake/eaf_base_api
|
|
17
17
|
Provides-Extra: hls
|
|
@@ -6,6 +6,7 @@ import time
|
|
|
6
6
|
import string
|
|
7
7
|
import shutil
|
|
8
8
|
import asyncio
|
|
9
|
+
import inspect
|
|
9
10
|
import logging
|
|
10
11
|
import traceback
|
|
11
12
|
import threading
|
|
@@ -14,7 +15,7 @@ from itertools import islice
|
|
|
14
15
|
from collections import deque
|
|
15
16
|
from functools import lru_cache
|
|
16
17
|
from urllib.parse import urljoin
|
|
17
|
-
from typing import Union, Callable, Tuple, Iterable, TYPE_CHECKING, AsyncGenerator, Coroutine, cast, List, Dict, Any
|
|
18
|
+
from typing import Union, Callable, Tuple, Iterable, TYPE_CHECKING, AsyncGenerator, Coroutine, cast, List, Dict, Any, Awaitable
|
|
18
19
|
from curl_cffi import CurlOpt # Used for DNS over HTTPS
|
|
19
20
|
from curl_cffi.requests.errors import RequestsError
|
|
20
21
|
from curl_cffi.requests import AsyncSession, Response
|
|
@@ -239,7 +240,7 @@ class Helper:
|
|
|
239
240
|
resource_instance = await resource_instance
|
|
240
241
|
return resource_instance
|
|
241
242
|
|
|
242
|
-
async def _make_video_safe(self, video_url: str) -> Any:
|
|
243
|
+
async def _make_video_safe(self, video_url: str, on_video_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None) -> Any:
|
|
243
244
|
"""
|
|
244
245
|
Fetches the HTML for a video URL and creates a Video object safely.
|
|
245
246
|
|
|
@@ -247,33 +248,68 @@ class Helper:
|
|
|
247
248
|
log errors, returning a VideoFetchError instead of crashing the iterator.
|
|
248
249
|
"""
|
|
249
250
|
logger = self.logger
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
251
|
+
attempt = 0
|
|
252
|
+
while True:
|
|
253
|
+
attempt += 1
|
|
254
|
+
start_timestamp = time.perf_counter()
|
|
255
|
+
try:
|
|
256
|
+
# Fetch the raw HTML content of the video page
|
|
257
|
+
html_content = await self.core.fetch(video_url)
|
|
258
|
+
|
|
259
|
+
if isinstance(html_content, Response):
|
|
260
|
+
if html_content.status_code == 404:
|
|
261
|
+
raise ResourceGone(f"Video returned 404 Not Found: {video_url}")
|
|
262
|
+
else:
|
|
263
|
+
raise NetworkingError(f"Unexpected response object with status {html_content.status_code} for {video_url}")
|
|
264
|
+
|
|
265
|
+
# Instantiate the video object using the provided factory
|
|
266
|
+
video_instance = self.video_factory(video_url, core=self.core, html_content=html_content)
|
|
267
|
+
|
|
268
|
+
# Handle both synchronous and asynchronous constructors
|
|
269
|
+
if asyncio.iscoroutine(video_instance):
|
|
270
|
+
video_instance = await video_instance
|
|
271
|
+
|
|
272
|
+
# Automatically call and await the init method if available (async initialization)
|
|
273
|
+
if hasattr(video_instance, "init") and callable(video_instance.init):
|
|
274
|
+
init_result = video_instance.init()
|
|
275
|
+
if asyncio.iscoroutine(init_result):
|
|
276
|
+
await init_result
|
|
277
|
+
|
|
278
|
+
elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
|
|
279
|
+
logger.debug("video_init ok url=%s (%.2f ms) attempt=%d", video_url, elapsed_ms, attempt)
|
|
280
|
+
return video_instance
|
|
281
|
+
|
|
282
|
+
except Exception as error:
|
|
283
|
+
elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
|
|
284
|
+
logger.warning("video_init FAILED url=%s (%.2f ms) attempt=%d: %s", video_url, elapsed_ms, attempt, error)
|
|
285
|
+
|
|
286
|
+
if on_video_error:
|
|
287
|
+
try:
|
|
288
|
+
should_retry = await on_video_error(video_url, error, attempt)
|
|
289
|
+
if should_retry:
|
|
290
|
+
continue
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.exception("on_video_error callback failed for url=%s: %s", video_url, e)
|
|
293
|
+
|
|
294
|
+
# Return a specialized error object so the caller can decide how to handle it
|
|
295
|
+
return VideoFetchError(video_url, error)
|
|
271
296
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
async def _fetch_page_safe(self, url: str, method: str, on_page_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None) -> Any:
|
|
298
|
+
attempt = 0
|
|
299
|
+
while True:
|
|
300
|
+
attempt += 1
|
|
301
|
+
try:
|
|
302
|
+
return await self.core.fetch(url, method=method)
|
|
303
|
+
except Exception as error:
|
|
304
|
+
self.logger.warning("PAGE FAILED url=%s attempt=%d: %s", url, attempt, error)
|
|
305
|
+
if on_page_error:
|
|
306
|
+
try:
|
|
307
|
+
should_retry = await on_page_error(url, error, attempt)
|
|
308
|
+
if should_retry:
|
|
309
|
+
continue
|
|
310
|
+
except Exception as e:
|
|
311
|
+
self.logger.exception("on_page_error callback failed for url=%s: %s", url, e)
|
|
312
|
+
raise
|
|
277
313
|
|
|
278
314
|
async def iterator(
|
|
279
315
|
self,
|
|
@@ -284,6 +320,9 @@ class Helper:
|
|
|
284
320
|
use_alternative_constructor: bool = False,
|
|
285
321
|
page_request_method: str = "GET",
|
|
286
322
|
video_request_method: str = "GET",
|
|
323
|
+
ignore_errors: bool = True,
|
|
324
|
+
on_video_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None,
|
|
325
|
+
on_page_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None,
|
|
287
326
|
) -> AsyncGenerator[Any, None]:
|
|
288
327
|
"""
|
|
289
328
|
The main scraping engine that orchestrates concurrent page and video processing.
|
|
@@ -368,7 +407,8 @@ class Helper:
|
|
|
368
407
|
"[%s] yield_ordered_results yielding pidx=%d vidx=%d (buffer size: %d)",
|
|
369
408
|
execution_id, current_key[0], current_key[1], len(result_buffer)
|
|
370
409
|
)
|
|
371
|
-
|
|
410
|
+
if not (ignore_errors and isinstance(item, (VideoFetchError, PageFetchError))):
|
|
411
|
+
ready_items.append(item)
|
|
372
412
|
count_flushed += 1
|
|
373
413
|
yield_video_cursor += 1
|
|
374
414
|
|
|
@@ -394,7 +434,7 @@ class Helper:
|
|
|
394
434
|
if use_alternative_constructor:
|
|
395
435
|
task = asyncio.create_task(self._create_alternative_resource(video_url))
|
|
396
436
|
else:
|
|
397
|
-
task = asyncio.create_task(self._make_video_safe(video_url))
|
|
437
|
+
task = asyncio.create_task(self._make_video_safe(video_url, on_video_error=on_video_error))
|
|
398
438
|
|
|
399
439
|
pending_video_tasks[task] = (page_idx, video_idx)
|
|
400
440
|
scheduled_count += 1
|
|
@@ -410,8 +450,7 @@ class Helper:
|
|
|
410
450
|
for _ in range(max_page_concurrency):
|
|
411
451
|
try:
|
|
412
452
|
p_idx, p_url = next(page_source_iterator)
|
|
413
|
-
|
|
414
|
-
task = asyncio.create_task(self.core.fetch(p_url, method=page_request_method))
|
|
453
|
+
task = asyncio.create_task(self._fetch_page_safe(p_url, method=page_request_method, on_page_error=on_page_error))
|
|
415
454
|
pending_page_tasks[task] = (p_idx, p_url)
|
|
416
455
|
logger.debug(
|
|
417
456
|
"[%s] initial PAGE scheduled pidx=%d url=%s",
|
|
@@ -467,7 +506,7 @@ class Helper:
|
|
|
467
506
|
if not should_stop_paging:
|
|
468
507
|
try:
|
|
469
508
|
next_p_idx, next_p_url = next(page_source_iterator)
|
|
470
|
-
next_task = asyncio.create_task(self.
|
|
509
|
+
next_task = asyncio.create_task(self._fetch_page_safe(next_p_url, method=page_request_method, on_page_error=on_page_error))
|
|
471
510
|
pending_page_tasks[next_task] = (next_p_idx, next_p_url)
|
|
472
511
|
except StopIteration:
|
|
473
512
|
pass
|
|
@@ -544,7 +583,7 @@ class Helper:
|
|
|
544
583
|
if not should_stop_paging:
|
|
545
584
|
try:
|
|
546
585
|
n_idx, n_url = next(page_source_iterator)
|
|
547
|
-
n_task = asyncio.create_task(self.
|
|
586
|
+
n_task = asyncio.create_task(self._fetch_page_safe(n_url, method=page_request_method, on_page_error=on_page_error))
|
|
548
587
|
pending_page_tasks[n_task] = (n_idx, n_url)
|
|
549
588
|
logger.debug("[%s] scheduled NEXT PAGE pidx=%d", execution_id, n_idx)
|
|
550
589
|
except StopIteration:
|
|
@@ -708,7 +747,7 @@ class BaseCore:
|
|
|
708
747
|
delay = self.configuration.request_delay
|
|
709
748
|
if delay and delay > 0:
|
|
710
749
|
time_since_last_request = time.time() - self.last_request_time
|
|
711
|
-
self.logger.debug("Time since last request: {:.2f seconds
|
|
750
|
+
self.logger.debug("Time since last request: {:.2f} seconds.".format(time_since_last_request))
|
|
712
751
|
if time_since_last_request < delay:
|
|
713
752
|
sleep_time = delay - time_since_last_request
|
|
714
753
|
self.logger.debug("Enforcing delay of {:.2f} seconds.".format(sleep_time))
|
|
@@ -735,7 +774,7 @@ class BaseCore:
|
|
|
735
774
|
self.initialize_session()
|
|
736
775
|
session = self.session
|
|
737
776
|
assert session is not None
|
|
738
|
-
cookies: Dict[str, Any] = cast(Dict[str, Any], cast(Any,
|
|
777
|
+
cookies: Dict[str, Any] = cast(Dict[str, Any], cast(Any, session.cookies.get_dict()))
|
|
739
778
|
if override:
|
|
740
779
|
cookies.update(override)
|
|
741
780
|
return cookies
|
|
@@ -1003,7 +1042,9 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1003
1042
|
# Resolve master content
|
|
1004
1043
|
assert m3u8 is not None
|
|
1005
1044
|
|
|
1006
|
-
if
|
|
1045
|
+
if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
|
|
1046
|
+
m3u8_url = m3u8_url()
|
|
1047
|
+
if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
|
|
1007
1048
|
m3u8_url = await m3u8_url
|
|
1008
1049
|
|
|
1009
1050
|
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
@@ -1038,6 +1079,12 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1038
1079
|
Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
|
|
1039
1080
|
"""
|
|
1040
1081
|
assert m3u8 is not None
|
|
1082
|
+
|
|
1083
|
+
if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
|
|
1084
|
+
m3u8_url = m3u8_url()
|
|
1085
|
+
if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
|
|
1086
|
+
m3u8_url = await m3u8_url
|
|
1087
|
+
|
|
1041
1088
|
if not m3u8_url.startswith("https://"):
|
|
1042
1089
|
master = m3u8.loads(m3u8_url)
|
|
1043
1090
|
else:
|
|
@@ -1207,8 +1254,10 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1207
1254
|
|
|
1208
1255
|
m3u8_url = getattr(video, "m3u8_base_url", None)
|
|
1209
1256
|
|
|
1210
|
-
if
|
|
1211
|
-
m3u8_url =
|
|
1257
|
+
if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
|
|
1258
|
+
m3u8_url = m3u8_url()
|
|
1259
|
+
if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
|
|
1260
|
+
m3u8_url = await m3u8_url
|
|
1212
1261
|
|
|
1213
1262
|
self.logger.info(
|
|
1214
1263
|
"""
|
|
@@ -1239,7 +1288,8 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1239
1288
|
return_report=return_report,
|
|
1240
1289
|
cleanup_on_stop=cleanup_on_stop,
|
|
1241
1290
|
keep_segment_dir=keep_segment_dir,
|
|
1242
|
-
ios_support=ios_support
|
|
1291
|
+
ios_support=ios_support,
|
|
1292
|
+
pre_resolved_m3u8_url=m3u8_url
|
|
1243
1293
|
)
|
|
1244
1294
|
|
|
1245
1295
|
def threaded(self, max_workers: int,
|
|
@@ -1262,7 +1312,8 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1262
1312
|
return_report: bool = False,
|
|
1263
1313
|
cleanup_on_stop: bool = True,
|
|
1264
1314
|
keep_segment_dir: bool = False,
|
|
1265
|
-
ios_support: bool = False
|
|
1315
|
+
ios_support: bool = False,
|
|
1316
|
+
pre_resolved_m3u8_url: str | None = None
|
|
1266
1317
|
) -> DownloadReport | bool:
|
|
1267
1318
|
"""
|
|
1268
1319
|
Threaded HLS segment downloader with optional resume state and stop flag.
|
|
@@ -1338,8 +1389,15 @@ a new Python file, import only m3u8 and see what error you get.
|
|
|
1338
1389
|
)
|
|
1339
1390
|
|
|
1340
1391
|
else:
|
|
1341
|
-
|
|
1342
|
-
|
|
1392
|
+
if pre_resolved_m3u8_url is not None:
|
|
1393
|
+
m3u8_master = pre_resolved_m3u8_url
|
|
1394
|
+
else:
|
|
1395
|
+
m3u8_master = getattr(video, "m3u8_base_url")
|
|
1396
|
+
assert m3u8_master is not None, "m3u8_base_url is missing from video object"
|
|
1397
|
+
if inspect.iscoroutinefunction(m3u8_master) or (callable(m3u8_master) and not isinstance(m3u8_master, str)):
|
|
1398
|
+
m3u8_master = m3u8_master()
|
|
1399
|
+
if inspect.iscoroutine(m3u8_master) or inspect.isawaitable(m3u8_master):
|
|
1400
|
+
m3u8_master = await m3u8_master
|
|
1343
1401
|
self.logger.info(f"Fetching segments for quality={quality} m3u8_url_master={m3u8_master}")
|
|
1344
1402
|
segments = await self.get_segments(quality=quality, m3u8_url_master=m3u8_master)
|
|
1345
1403
|
total_before = len(segments)
|
|
@@ -1923,7 +1981,7 @@ allow_multipart=%s""", url, path, max_retries, read_timeout, bool(stop_event and
|
|
|
1923
1981
|
file_size = int(head_resp.headers.get("Content-Length", 0))
|
|
1924
1982
|
accept_ranges = head_resp.headers.get("Accept-Ranges", "")
|
|
1925
1983
|
except Exception as e:
|
|
1926
|
-
self.logger.warning("Failed to fetch HEAD info for concurrent check: %
|
|
1984
|
+
self.logger.warning("Failed to fetch HEAD info for concurrent check: %s.", e)
|
|
1927
1985
|
|
|
1928
1986
|
# 2. Execute Fast Multipart Download if supported and allowed
|
|
1929
1987
|
if allow_multipart and file_size > 0 and accept_ranges == "bytes":
|
|
@@ -4,10 +4,10 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "eaf_base_api"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.1"
|
|
8
8
|
description = "A base API for EchterAlsFake's Porn APIs"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
11
|
license = "LGPL-3.0-or-later"
|
|
12
12
|
license-files = ["LICENSE*"]
|
|
13
13
|
|
|
@@ -28,7 +28,7 @@ dependencies = [
|
|
|
28
28
|
hls = [
|
|
29
29
|
"m3u8",
|
|
30
30
|
# av needs Python >= 3.10, so guard it (otherwise `pip install ...[hls]` fails on 3.8/3.9)
|
|
31
|
-
"av; python_version >= '3.
|
|
31
|
+
"av; python_version >= '3.12'",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.urls]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|