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 +16 -0
- aa_meta-0.1.0/README.md +0 -0
- aa_meta-0.1.0/pyproject.toml +39 -0
- aa_meta-0.1.0/pyproject.toml.orig +38 -0
- aa_meta-0.1.0/src/aa_meta/__init__.py +3 -0
- aa_meta-0.1.0/src/aa_meta/api.py +55 -0
- aa_meta-0.1.0/src/aa_meta/authors.py +69 -0
- aa_meta-0.1.0/src/aa_meta/cli.py +520 -0
- aa_meta-0.1.0/src/aa_meta/config.py +58 -0
- aa_meta-0.1.0/src/aa_meta/cookies.py +125 -0
- aa_meta-0.1.0/src/aa_meta/details.py +30 -0
- aa_meta-0.1.0/src/aa_meta/download.py +53 -0
- aa_meta-0.1.0/src/aa_meta/models.py +114 -0
- aa_meta-0.1.0/src/aa_meta/parse.py +210 -0
- aa_meta-0.1.0/src/aa_meta/py.typed +0 -0
- aa_meta-0.1.0/src/aa_meta/search.py +156 -0
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
|
+
|
aa_meta-0.1.0/README.md
ADDED
|
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,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
|