aa-meta 0.1.0__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.
aa_meta-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.3
2
+ Name: aa-meta
3
+ Version: 0.1.0
4
+ Summary: Search and download books from Anna's Archive
5
+ Author: Maxwell Ludden
6
+ Author-email: Maxwell Ludden <maxludden@me.com>
7
+ Requires-Dist: beautifulsoup4>=4.15.0
8
+ Requires-Dist: browser-cookie3>=0.20.1
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: loguru>=0.7.3
11
+ Requires-Dist: rich>=15.0.0
12
+ Requires-Dist: rich-gradient>=0.3.13
13
+ Requires-Dist: typer>=0.27.3
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+
File without changes
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "aa-meta"
3
+ version = "0.1.0"
4
+ description = "Search and download books from Anna's Archive"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "beautifulsoup4>=4.15.0",
9
+ "browser-cookie3>=0.20.1",
10
+ "httpx>=0.28.1",
11
+ "loguru>=0.7.3",
12
+ "rich>=15.0.0",
13
+ "rich-gradient>=0.3.13",
14
+ "typer>=0.27.3",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Maxwell Ludden"
19
+ email = "maxludden@me.com"
20
+
21
+ [project.scripts]
22
+ aa = "aa_meta.cli:app"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.22,<0.13.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "mypy>=2.4.0",
31
+ "pytest>=9.1.1",
32
+ "respx>=0.23.1",
33
+ "ruff>=0.17.0",
34
+ "types-beautifulsoup4>=4.12.0.20250516",
35
+ ]
36
+
37
+ [[tool.mypy.overrides]]
38
+ module = "browser_cookie3"
39
+ ignore_missing_imports = true
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "aa-meta"
3
+ version = "0.1.0"
4
+ description = "Search and download books from Anna's Archive"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Maxwell Ludden", email = "maxludden@me.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "beautifulsoup4>=4.15.0",
12
+ "browser-cookie3>=0.20.1",
13
+ "httpx>=0.28.1",
14
+ "loguru>=0.7.3",
15
+ "rich>=15.0.0",
16
+ "rich-gradient>=0.3.13",
17
+ "typer>=0.27.3",
18
+ ]
19
+
20
+ [project.scripts]
21
+ aa = "aa_meta.cli:app"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.22,<0.13.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "mypy>=2.4.0",
30
+ "pytest>=9.1.1",
31
+ "respx>=0.23.1",
32
+ "ruff>=0.17.0",
33
+ "types-beautifulsoup4>=4.12.0.20250516",
34
+ ]
35
+
36
+ [[tool.mypy.overrides]]
37
+ module = "browser_cookie3"
38
+ ignore_missing_imports = true
@@ -0,0 +1,3 @@
1
+ """aa-meta: search and download books from Anna's Archive."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,55 @@
1
+ """Client for the Anna's Archive fast-download JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .config import USER_AGENT, Settings
11
+
12
+
13
+ class ApiError(RuntimeError):
14
+ """Raised when the API returns an error or no download URL."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FastDownload:
19
+ """Successful fast-download response."""
20
+
21
+ url: str
22
+ info: dict[str, Any]
23
+
24
+
25
+ def fast_download(
26
+ client: httpx.Client,
27
+ settings: Settings,
28
+ md5: str,
29
+ key: str,
30
+ path_index: int | None = None,
31
+ domain_index: int | None = None,
32
+ ) -> FastDownload:
33
+ """Resolve a member fast-download URL for ``md5``.
34
+
35
+ Raises:
36
+ ApiError: on a non-2xx response or a null ``download_url``.
37
+ """
38
+ params: dict[str, str | int] = {"md5": md5, "key": key}
39
+ if path_index is not None:
40
+ params["path_index"] = path_index
41
+ if domain_index is not None:
42
+ params["domain_index"] = domain_index
43
+ resp = client.get(
44
+ f"{settings.origin}/dyn/api/fast_download.json",
45
+ params=params,
46
+ headers={"User-Agent": USER_AGENT},
47
+ )
48
+ try:
49
+ data: dict[str, Any] = resp.json()
50
+ except ValueError as exc:
51
+ raise ApiError(f"HTTP {resp.status_code}: non-JSON response") from exc
52
+ url = data.get("download_url")
53
+ if resp.status_code not in (200, 204) or not url:
54
+ raise ApiError(data.get("error") or f"HTTP {resp.status_code}")
55
+ return FastDownload(url=url, info=data.get("account_fast_download_info") or {})
@@ -0,0 +1,69 @@
1
+ """Colour-code the authors of search results with a ``rich_gradient`` Spectrum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from rich.style import Style
8
+ from rich.text import Text
9
+ from rich_gradient.spectrum import COLOR_STOPS, Spectrum
10
+
11
+
12
+ def split_authors(raw: str) -> list[str]:
13
+ """Split a site author string into individual names (heuristic).
14
+
15
+ The site separates authors with ``;``. Some sources use commas instead, which
16
+ is ambiguous with ``Last, First``: a single comma is treated as one
17
+ ``Last, First`` name, two or more as a comma-separated list.
18
+ """
19
+ if ";" in raw:
20
+ parts = raw.split(";")
21
+ elif raw.count(",") >= 2:
22
+ parts = raw.split(",")
23
+ else:
24
+ parts = [raw]
25
+ return [p.strip() for p in parts if p.strip()]
26
+
27
+
28
+ def author_key(name: str) -> str:
29
+ """Normalise a name so ``Herbert, Frank`` and ``Frank Herbert`` match."""
30
+ if "," in name:
31
+ last, _, first = name.partition(",")
32
+ name = f"{first} {last}"
33
+ return " ".join(name.replace(".", "").casefold().split())
34
+
35
+
36
+ def format_authors(raw_authors: Sequence[str], *, seed: int = 0) -> list[Text]:
37
+ """Return one colour-coded :class:`~rich.text.Text` per author string.
38
+
39
+ Every distinct author (by :func:`author_key`) gets one colour from a
40
+ :class:`~rich_gradient.spectrum.Spectrum`, so the same person looks the same
41
+ in every row. ``Spectrum`` yields consecutive colours around the ring, so the
42
+ full ring is built and colours are picked evenly spaced to keep a handful of
43
+ authors visually distinct; with more authors than colours, colours repeat.
44
+
45
+ Args:
46
+ raw_authors: The author field of each result, in row order.
47
+ seed: Rotates where the colour ring starts; same seed, same colours.
48
+
49
+ Returns:
50
+ A list the same length as ``raw_authors``; empty authors render as a dim
51
+ ``Unknown``.
52
+ """
53
+ rows = [split_authors(raw) for raw in raw_authors]
54
+ keys = list(dict.fromkeys(author_key(n) for names in rows for n in names))
55
+ ring = Spectrum(hues=len(COLOR_STOPS), seed=seed).styles
56
+ step = max(len(ring) / max(len(keys), 1), 1.0)
57
+ style_of = {k: ring[int(i * step) % len(ring)] for i, k in enumerate(keys)}
58
+
59
+ texts: list[Text] = []
60
+ for names in rows:
61
+ text = Text(no_wrap=False)
62
+ if not names:
63
+ text.append("Unknown", style="dim")
64
+ for i, name in enumerate(names):
65
+ if i:
66
+ text.append("; ", style="dim")
67
+ text.append(name, style=style_of[author_key(name)] + Style(bold=True))
68
+ texts.append(text)
69
+ return texts