eaf-base-api 4.2.1__tar.gz → 4.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 4.2.1
3
+ Version: 4.2.2
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>
@@ -1,4 +1,8 @@
1
1
  __all__ = [
2
+ "append_url_path",
3
+ "profile_base_url",
4
+ "replace_url_path",
5
+ "update_url_query",
2
6
  "BaseCore",
3
7
  "BaseMedia",
4
8
  "Cache",
@@ -69,6 +73,7 @@ __all__ = [
69
73
 
70
74
 
71
75
  from base_api.modules import errors
76
+ from base_api.modules.urls import append_url_path, profile_base_url, replace_url_path, update_url_query
72
77
  from base_api.modules.progress_bars import Callback
73
78
  from base_api.modules.errors import (
74
79
  DataNotLoadedError,
@@ -29,6 +29,7 @@ from typing import (
29
29
 
30
30
 
31
31
  # 1. Standardize on relative imports
32
+ from base_api.modules.urls import FetchedText
32
33
  from base_api.modules.errors import *
33
34
  from base_api.modules.type_hints import (
34
35
  DownloadReport, ResultOrder, ErrorMode, ErrorAction, ScrapeStage,
@@ -422,6 +423,9 @@ class BaseMedia:
422
423
  url: str
423
424
  core: object
424
425
 
426
+ # Transport metadata is separate from lazily loaded fields and the caller's URL.
427
+ resolved_url: str | None = field(default=None, init=False, repr=False, compare=False)
428
+
425
429
  loader_methods: ClassVar[Mapping[str, str]] = {}
426
430
 
427
431
  _load_lock: asyncio.Lock = field(
@@ -2097,7 +2101,10 @@ class BaseCore:
2097
2101
  params=params,
2098
2102
  retry_non_idempotent=retry_non_idempotent,
2099
2103
  )
2100
- content = self._decode_response(response, url, self.logger)
2104
+ content = FetchedText(
2105
+ self._decode_response(response, url, self.logger),
2106
+ url=getattr(response, "url", None) or url,
2107
+ )
2101
2108
  if key is not None:
2102
2109
  self.cache.set_response(key, content)
2103
2110
  assert pending is not None
@@ -7,6 +7,7 @@ from functools import wraps
7
7
 
8
8
  from base_api.modules import errors
9
9
  from base_api.modules.logger import class_name, get_logger, log_context
10
+ from base_api.modules.urls import same_site_url
10
11
 
11
12
 
12
13
  async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
@@ -20,6 +21,10 @@ async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
20
21
  with log_context(owner, url):
21
22
  try:
22
23
  content = await core.fetch_text(url)
24
+ resolved = getattr(content, "url", None)
25
+ if (isinstance(resolved, str) and owner is not None
26
+ and getattr(owner, "url", None) == url and same_site_url(url, resolved)):
27
+ owner.resolved_url = resolved
23
28
  return json.loads(content, strict=False) if get_json else content
24
29
  except errors.DownloadCancelled:
25
30
  raise
@@ -0,0 +1,51 @@
1
+ """Small URL operations shared by provider pagination and profile adapters."""
2
+
3
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
4
+
5
+
6
+ def update_url_query(url: str, **changes: str | int | None) -> str:
7
+ """Replace named query keys without losing filters or treating anchors as HTTP."""
8
+ parsed = urlsplit(url)
9
+ pairs = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key not in changes]
10
+ pairs.extend((key, str(value)) for key, value in changes.items() if value is not None)
11
+ return urlunsplit(parsed._replace(query=urlencode(pairs), fragment=""))
12
+
13
+
14
+ def replace_url_path(url: str, path: str) -> str:
15
+ parsed = urlsplit(url)
16
+ return urlunsplit(parsed._replace(path=path, fragment=""))
17
+
18
+
19
+ def append_url_path(url: str, *parts: str | int) -> str:
20
+ path = urlsplit(url).path.rstrip("/")
21
+ return replace_url_path(url, path + "/" + "/".join(str(part).strip("/") for part in parts))
22
+
23
+
24
+ def profile_base_url(url: str, *, depth: int = 2) -> str:
25
+ """Remove a provider's tab/page suffix, retaining its host, slug and filters."""
26
+ parsed = urlsplit(url)
27
+ parts = [part for part in parsed.path.split("/") if part]
28
+ if len(parts) < depth:
29
+ raise ValueError("An individual profile URL with a name or ID is required")
30
+ return update_url_query(replace_url_path(url, "/" + "/".join(parts[:depth])), page=None)
31
+
32
+
33
+ def same_site_url(original: str, resolved: str) -> bool:
34
+ """Accept response metadata on the same site, including localized subdomains."""
35
+ before, after = urlsplit(original), urlsplit(resolved)
36
+ if after.scheme not in {"http", "https"} or not before.hostname or not after.hostname:
37
+ return False
38
+ # The supported providers use these two-label domains, except RedTube Brazil.
39
+ depth = 3 if before.hostname.endswith(".com.br") else 2
40
+ return before.hostname.split(".")[-depth:] == after.hostname.split(".")[-depth:]
41
+
42
+
43
+ class FetchedText(str):
44
+ """A string that retains response identity through text caches and parsers."""
45
+
46
+ url: str
47
+
48
+ def __new__(cls, content: str, *, url: str):
49
+ value = super().__new__(cls, content)
50
+ value.url = url
51
+ return value
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.2.1"
7
+ version = "4.2.2"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  requires-python = ">=3.12"
10
10
  license = "AGPL-3.0-or-later"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.2.1"
7
+ version = "4.2.2"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
File without changes
File without changes