aw-cli 2.3__tar.gz → 2.3.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.
- aw_cli-2.3.2/.github/ISSUE_TEMPLATE/bug_report.md +65 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/.gitignore +3 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/PKG-INFO +3 -2
- {aw_cli-2.3 → aw_cli-2.3.2}/pyproject.toml +8 -1
- {aw_cli-2.3/src/aw_cli/fzf → aw_cli-2.3.2/src/aw_cli/interface}/fzf.py +31 -10
- aw_cli-2.3.2/src/aw_cli/providers/__init__.py +36 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/providers/animeunity.py +14 -14
- aw_cli-2.3.2/src/aw_cli/providers/animeworld.py +217 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/providers/local.py +1 -1
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/providers/provider.py +24 -12
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/run.py +274 -119
- aw_cli-2.3.2/tests/conftest.py +128 -0
- aw_cli-2.3.2/tests/core/__init__.py +0 -0
- aw_cli-2.3.2/tests/interface/__init__.py +0 -0
- {aw_cli-2.3/tests → aw_cli-2.3.2/tests/interface}/test_fzf.py +36 -5
- aw_cli-2.3.2/tests/providers/__init__.py +0 -0
- aw_cli-2.3.2/tests/providers/test_animeunity.py +90 -0
- aw_cli-2.3.2/tests/providers/test_animeworld.py +90 -0
- aw_cli-2.3.2/tests/providers/test_provider.py +42 -0
- aw_cli-2.3.2/uv.lock +759 -0
- aw_cli-2.3/src/aw_cli/providers/__init__.py +0 -12
- aw_cli-2.3/src/aw_cli/providers/animeworld.py +0 -122
- aw_cli-2.3/tests/empty +0 -25
- aw_cli-2.3/tests/search_dragonball +0 -216
- aw_cli-2.3/tests/search_onepiece +0 -127
- aw_cli-2.3/tests/theeminenceinshadowep13 +0 -499
- aw_cli-2.3/uv.lock +0 -749
- {aw_cli-2.3 → aw_cli-2.3.2}/.github/workflows/python-publish.yml +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/.github/workflows/release.yml +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/.pre-commit-config.yaml +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/LICENSE +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/README.md +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/disclaimer.md +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/__init__.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/anilist.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/anime.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/arg_parser.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/download.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/history.py +0 -0
- {aw_cli-2.3/src/aw_cli/fzf → aw_cli-2.3.2/src/aw_cli/interface}/__init__.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/update.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/src/aw_cli/utilities.py +0 -0
- {aw_cli-2.3 → aw_cli-2.3.2}/tests/__init__.py +0 -0
- {aw_cli-2.3/tests → aw_cli-2.3.2/tests/core}/test_anime.py +0 -0
- {aw_cli-2.3/tests → aw_cli-2.3.2/tests/core}/test_history.py +0 -0
- {aw_cli-2.3/tests → aw_cli-2.3.2/tests/core}/test_run.py +0 -0
- {aw_cli-2.3/tests → aw_cli-2.3.2/tests/interface}/test_rich_integration.py +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
## Descrizione del problema
|
|
2
|
+
|
|
3
|
+
Descrivi chiaramente il problema riscontrato.
|
|
4
|
+
|
|
5
|
+
## Passi per riprodurre
|
|
6
|
+
|
|
7
|
+
Esempio:
|
|
8
|
+
|
|
9
|
+
1. Esegui `aw-cli ...`
|
|
10
|
+
2. Seleziona ...
|
|
11
|
+
3. Il problema si verifica quando ...
|
|
12
|
+
|
|
13
|
+
## Informazioni sull'ambiente
|
|
14
|
+
|
|
15
|
+
### Versione di aw-cli
|
|
16
|
+
|
|
17
|
+
Esegui:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
aw-cli -v
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Output:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Sistema operativo
|
|
30
|
+
|
|
31
|
+
Esempio: Fedora 40, Ubuntu 24.04, Arch Linux, macOS, Android, Windows WSL, ecc.
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Metodo di installazione
|
|
38
|
+
|
|
39
|
+
- [ ] `uv`
|
|
40
|
+
- [ ] `pipx`
|
|
41
|
+
- [ ] `pip`
|
|
42
|
+
- [ ] Installazione manuale / da sorgente
|
|
43
|
+
- [ ] Altro (specifica qui)
|
|
44
|
+
|
|
45
|
+
### Provider
|
|
46
|
+
|
|
47
|
+
- [ ] AnimeWorld
|
|
48
|
+
- [ ] AnimeUnity
|
|
49
|
+
|
|
50
|
+
### Player
|
|
51
|
+
|
|
52
|
+
- [ ] mpv
|
|
53
|
+
- [ ] vlc
|
|
54
|
+
|
|
55
|
+
## Log / output del terminale
|
|
56
|
+
|
|
57
|
+
Incolla qui l'output completo dell'errore, se presente.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Informazioni aggiuntive
|
|
64
|
+
|
|
65
|
+
Aggiungi qualsiasi altro dettaglio utile, screenshot o contesto.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aw-cli
|
|
3
|
-
Version: 2.3
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Guarda anime dal terminale e molto altro!
|
|
5
|
+
Project-URL: Homepage, https://github.com/fexh10/aw-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/fexh10/aw-cli
|
|
5
7
|
License-Expression: GPL-3.0
|
|
6
8
|
License-File: LICENSE
|
|
7
9
|
Requires-Python: >=3.10
|
|
@@ -11,7 +13,6 @@ Requires-Dist: pydantic>=2.12.5
|
|
|
11
13
|
Requires-Dist: regex>=2025.9.18
|
|
12
14
|
Requires-Dist: rich>=13.9.4
|
|
13
15
|
Requires-Dist: toml>=0.10.2
|
|
14
|
-
Requires-Dist: wheel>=0.45.1
|
|
15
16
|
Description-Content-Type: text/markdown
|
|
16
17
|
|
|
17
18
|
# aw-cli
|
|
@@ -12,9 +12,12 @@ dependencies = [
|
|
|
12
12
|
"regex>=2025.9.18",
|
|
13
13
|
"rich>=13.9.4",
|
|
14
14
|
"toml>=0.10.2",
|
|
15
|
-
"wheel>=0.45.1",
|
|
16
15
|
]
|
|
17
16
|
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/fexh10/aw-cli"
|
|
19
|
+
Repository = "https://github.com/fexh10/aw-cli"
|
|
20
|
+
|
|
18
21
|
[project.scripts]
|
|
19
22
|
aw-cli = "aw_cli.run:main"
|
|
20
23
|
|
|
@@ -31,3 +34,7 @@ dev = [
|
|
|
31
34
|
"pyright>=1.1.406",
|
|
32
35
|
"pytest>=8.4.2",
|
|
33
36
|
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "-ra"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import sys
|
|
3
3
|
import re
|
|
4
|
+
import socket
|
|
5
|
+
import json
|
|
4
6
|
import argparse
|
|
5
7
|
import urllib.request
|
|
6
8
|
|
|
7
|
-
import aw_cli.utilities as ut
|
|
8
|
-
|
|
9
9
|
|
|
10
10
|
class Fzf:
|
|
11
11
|
"""
|
|
@@ -20,7 +20,7 @@ class Fzf:
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
def __init__(self):
|
|
23
|
-
self._port: int =
|
|
23
|
+
self._port: int = 0
|
|
24
24
|
|
|
25
25
|
def run(
|
|
26
26
|
self,
|
|
@@ -38,7 +38,10 @@ class Fzf:
|
|
|
38
38
|
multi: se True abilita la selezione multipla (Ctrl+A = toggle all).
|
|
39
39
|
filter: se True abilita il filtro per range (formato: inizio-fine).
|
|
40
40
|
"""
|
|
41
|
-
|
|
41
|
+
self._port = self._find_free_port()
|
|
42
|
+
cmd = self._build_cmd(
|
|
43
|
+
elements=elements, prompt=prompt, multi=multi, filter=filter
|
|
44
|
+
)
|
|
42
45
|
|
|
43
46
|
try:
|
|
44
47
|
while True:
|
|
@@ -82,6 +85,16 @@ class Fzf:
|
|
|
82
85
|
except OSError:
|
|
83
86
|
pass
|
|
84
87
|
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _find_free_port() -> int:
|
|
90
|
+
"""
|
|
91
|
+
Restituisce una porta TCP libera assegnata dal sistema operativo.
|
|
92
|
+
Usare questa porta per --listen evita conflitti e TIME_WAIT tra riavvii di fzf.
|
|
93
|
+
"""
|
|
94
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
95
|
+
s.bind(("", 0))
|
|
96
|
+
return s.getsockname()[1]
|
|
97
|
+
|
|
85
98
|
def _build_cmd(
|
|
86
99
|
self,
|
|
87
100
|
elements: list[str],
|
|
@@ -109,11 +122,12 @@ class Fzf:
|
|
|
109
122
|
cmd += ["--multi", "--bind", "ctrl-a:toggle-all"]
|
|
110
123
|
|
|
111
124
|
if filter:
|
|
112
|
-
reload_cmd = f"{sys.executable} -m aw_cli.
|
|
125
|
+
reload_cmd = f"{sys.executable} -m aw_cli.interface.fzf --filter {{q}} --episodes '{json.dumps(elements)}'"
|
|
113
126
|
cmd += [
|
|
114
127
|
"--phony",
|
|
115
|
-
"--bind",
|
|
116
|
-
"
|
|
128
|
+
"--bind",
|
|
129
|
+
f"change:reload({reload_cmd})",
|
|
130
|
+
"--header=Range: inizio-fine. crtl+A seleziona tutto, tab/shift+tab selezione singola",
|
|
117
131
|
]
|
|
118
132
|
|
|
119
133
|
return cmd
|
|
@@ -132,7 +146,10 @@ def _filter_episodes(query: str, episodes_raw: str) -> None:
|
|
|
132
146
|
ha successo se i due intervalli si intersecano.
|
|
133
147
|
"""
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
try:
|
|
150
|
+
episodes: list[str] = json.loads(episodes_raw)
|
|
151
|
+
except (json.JSONDecodeError, ValueError):
|
|
152
|
+
return
|
|
136
153
|
|
|
137
154
|
if not query:
|
|
138
155
|
print("\n".join(episodes))
|
|
@@ -185,8 +202,12 @@ def _filter_episodes(query: str, episodes_raw: str) -> None:
|
|
|
185
202
|
|
|
186
203
|
if __name__ == "__main__":
|
|
187
204
|
parser = argparse.ArgumentParser()
|
|
188
|
-
parser.add_argument(
|
|
189
|
-
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--filter", type=str, default="", help="Query per filtrare gli episodi"
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--episodes", type=str, required=True, help="Lista episodi JSON"
|
|
210
|
+
)
|
|
190
211
|
args = parser.parse_args()
|
|
191
212
|
|
|
192
213
|
_filter_episodes(args.filter, args.episodes)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pyright: reportUnusedImport=false
|
|
2
|
+
from .provider import Provider
|
|
3
|
+
|
|
4
|
+
PROVIDERS_AVAILABLE = {
|
|
5
|
+
"animeunity": ("aw_cli.providers.animeunity", "Animeunity"),
|
|
6
|
+
"animeworld": ("aw_cli.providers.animeworld", "Animeworld"),
|
|
7
|
+
# "animeworld-api": ("aw_cli.providers.animeworld_api", "AnimeWorldAPI"),
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
def create_provider(name: str) -> Provider:
|
|
11
|
+
"""
|
|
12
|
+
Crea un'istanza del provider in base al nome.
|
|
13
|
+
Usa importazione lazy per caricare solo il modulo necessario.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
name (str): il nome del provider (es. 'animeunity', 'animeworld').
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Provider: l'istanza del provider creato.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: se il nome del provider non è disponibile.
|
|
23
|
+
"""
|
|
24
|
+
if name not in PROVIDERS_AVAILABLE:
|
|
25
|
+
raise ValueError(f"Provider '{name}' non disponibile. Scegli tra: {list(PROVIDERS_AVAILABLE.keys())}")
|
|
26
|
+
|
|
27
|
+
module_name, class_name = PROVIDERS_AVAILABLE[name]
|
|
28
|
+
import importlib
|
|
29
|
+
module = importlib.import_module(module_name)
|
|
30
|
+
provider_class = getattr(module, class_name)
|
|
31
|
+
return provider_class()
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Provider",
|
|
35
|
+
"create_provider",
|
|
36
|
+
]
|
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import json
|
|
3
3
|
from html import unescape
|
|
4
|
+
from functools import cached_property
|
|
4
5
|
from ..anime import Anime, AnimeStatus
|
|
5
6
|
from .provider import Provider, HTTPError
|
|
6
7
|
|
|
7
8
|
class Animeunity(Provider):
|
|
8
9
|
"""
|
|
9
10
|
Classe che gestisce il collegamento con Animeworld.
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
Attributes:
|
|
12
13
|
_url (str): l'url del sito di Animeworld.
|
|
13
14
|
_headers (dict): gli headers da utilizzare per le richieste HTTP.
|
|
14
15
|
_cookies (dict): i cookies da utilizzare per le richieste HTTP.
|
|
15
16
|
_visited (dict): le pagine già visitate.
|
|
16
17
|
"""
|
|
17
|
-
def __init__(self):
|
|
18
|
-
super().__init__("https://www.animeunity.so")
|
|
19
|
-
self._get_token() # Assicura che i token/cookie siano aggiornati
|
|
18
|
+
def __init__(self, client=None):
|
|
19
|
+
super().__init__("https://www.animeunity.so", client=client)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
def
|
|
23
|
-
|
|
21
|
+
@cached_property
|
|
22
|
+
def _session(self) -> str:
|
|
23
|
+
"""Inizializza la sessione (token CSRF + cookies) al primo accesso. Ritorna l'HTML."""
|
|
24
24
|
response = self.Client.get(self.BASE_URL)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
csrf_match = re.search(r'<meta.*?name="csrf-token".*?content="([^"]*)".*?>', self.html)
|
|
25
|
+
html = response.text
|
|
26
|
+
csrf_match = re.search(r'<meta.*?name="csrf-token".*?content="([^"]*)".*?>', html)
|
|
28
27
|
if not csrf_match:
|
|
29
28
|
raise ValueError("CSRF token not found in the HTML")
|
|
30
|
-
|
|
31
|
-
self.Client.headers['x-csrf-token'] = csrf_token
|
|
29
|
+
self.Client.headers['x-csrf-token'] = csrf_match.group(1)
|
|
32
30
|
self.Client.cookies['animeunity_session'] = response.cookies.get('animeunity_session') or ""
|
|
31
|
+
return html
|
|
33
32
|
|
|
34
33
|
|
|
35
34
|
def _search(self, input: str) -> list[Anime]:
|
|
35
|
+
_ = self._session # trigger lazy init
|
|
36
36
|
search_url = f"{self.BASE_URL}/livesearch"
|
|
37
37
|
response = self.Client.post(
|
|
38
38
|
url=search_url,
|
|
@@ -52,7 +52,7 @@ class Animeunity(Provider):
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def _latest(self, filter, specials) -> list[Anime]:
|
|
55
|
-
regex = re.search(r'<layout-items[^>]*items-json="([^"]*)"', self.
|
|
55
|
+
regex = re.search(r'<layout-items[^>]*items-json="([^"]*)"', self._session)
|
|
56
56
|
if not regex:
|
|
57
57
|
raise ValueError("Errore nel parsing della pagina principale")
|
|
58
58
|
|
|
@@ -131,7 +131,7 @@ class Animeunity(Provider):
|
|
|
131
131
|
|
|
132
132
|
def _parse_info(self, data: dict) -> tuple[str, str, int, AnimeStatus, dict[str, str]]:
|
|
133
133
|
title = data['title_eng'] or data['title'] or data['title_it']
|
|
134
|
-
last_ep = str(data['real_episodes_count']) if 'real_episodes_count' in data else "0"
|
|
134
|
+
last_ep = str(data['real_episodes_count']) if 'real_episodes_count' in data else "0"
|
|
135
135
|
anilist_id = data['anilist_id']
|
|
136
136
|
match data['status']:
|
|
137
137
|
case "In Corso":
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
import re
|
|
3
|
+
from html import unescape
|
|
4
|
+
from urllib.parse import quote_plus
|
|
5
|
+
from ..anime import Anime, AnimeStatus
|
|
6
|
+
from .provider import Provider, HTTPError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Animeworld(Provider):
|
|
10
|
+
"""
|
|
11
|
+
Classe che gestisce il collegamento con Animeworld.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
_url (str): l'url del sito di Animeworld.
|
|
15
|
+
_headers (dict): gli headers da utilizzare per le richieste HTTP.
|
|
16
|
+
_cookies (dict): i cookies da utilizzare per le richieste HTTP.
|
|
17
|
+
_visited (dict): le pagine già visitate.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client=None):
|
|
21
|
+
super().__init__("https://www.animeworld.ac", client=client)
|
|
22
|
+
self._visited = {}
|
|
23
|
+
|
|
24
|
+
@lru_cache
|
|
25
|
+
def _get_html(self, url: str) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Ottiene l'html della pagina web `url`.
|
|
28
|
+
Gestisce cookie JavaScript (document.cookie) e imposta il csrf-token se presente.
|
|
29
|
+
"""
|
|
30
|
+
if not re.match(r"^https?://", url):
|
|
31
|
+
raise HTTPError("Errore 404: pagina non trovata")
|
|
32
|
+
|
|
33
|
+
response = self.Client.get(url)
|
|
34
|
+
|
|
35
|
+
# Gestione cookie impostati tramite JavaScript (document.cookie)
|
|
36
|
+
cookie_match = re.search(
|
|
37
|
+
r'document\.cookie\s*?=\s*?"([^=]+)=([^";]+)', response.text
|
|
38
|
+
)
|
|
39
|
+
if cookie_match:
|
|
40
|
+
name, value = cookie_match.groups()
|
|
41
|
+
self.Client.cookies.set(name, value)
|
|
42
|
+
response = self.Client.get(url)
|
|
43
|
+
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
|
|
46
|
+
# Estrai e aggiorna il csrf-token se presente nella pagina per le chiamate API
|
|
47
|
+
csrf_token_match = re.search(
|
|
48
|
+
r'<meta.*?id="csrf-token"\s*?content="(.*?)">', response.text
|
|
49
|
+
)
|
|
50
|
+
if csrf_token_match:
|
|
51
|
+
self.Client.headers["csrf-token"] = csrf_token_match.group(1)
|
|
52
|
+
|
|
53
|
+
if response.status_code != 200 or "Errore 404" in response.text:
|
|
54
|
+
raise HTTPError("Errore 404: pagina non trovata")
|
|
55
|
+
|
|
56
|
+
return response.text
|
|
57
|
+
|
|
58
|
+
def _search(self, input: str) -> list[Anime]:
|
|
59
|
+
# Assicuriamoci che il client abbia cookies/csrf-token caricando prima la homepage
|
|
60
|
+
if "csrf-token" not in self.Client.headers:
|
|
61
|
+
try:
|
|
62
|
+
self._get_html(self.BASE_URL)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
response = self.Client.post(
|
|
67
|
+
f"{self.BASE_URL}/api/search/v2", params={"keyword": input}
|
|
68
|
+
)
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
|
|
71
|
+
data = response.json()
|
|
72
|
+
if "error" in data or "animes" not in data:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
animes = []
|
|
76
|
+
for elem in data["animes"]:
|
|
77
|
+
link = elem.get("link")
|
|
78
|
+
identifier = elem.get("identifier")
|
|
79
|
+
if link and identifier:
|
|
80
|
+
url = f"{self.BASE_URL}/play/{link}.{identifier}"
|
|
81
|
+
else:
|
|
82
|
+
url = self.BASE_URL
|
|
83
|
+
|
|
84
|
+
name = elem.get("name", "")
|
|
85
|
+
anime = Anime(unescape(name), url)
|
|
86
|
+
|
|
87
|
+
# Pre-populate all metadata from search JSON using a clean helper
|
|
88
|
+
anilist_id, status, info = self._parse_info(elem)
|
|
89
|
+
anime.set_info(anilist_id, status, info)
|
|
90
|
+
animes.append(anime)
|
|
91
|
+
return animes
|
|
92
|
+
|
|
93
|
+
def _latest(self, filter: str, specials: bool) -> list[Anime]:
|
|
94
|
+
html = self._get_html(self.BASE_URL)
|
|
95
|
+
animes = list[Anime]()
|
|
96
|
+
|
|
97
|
+
# Trova tutti i tag <a> con classe "poster" per estrarre in modo robusto
|
|
98
|
+
for a_tag in re.findall(r'<a\s+[^>]*class="poster"[^>]*>', html):
|
|
99
|
+
href_match = re.search(r'href="([^"]+)"', a_tag)
|
|
100
|
+
title_match = re.search(r'title="([^"]+)"', a_tag)
|
|
101
|
+
if not href_match or not title_match:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
url = href_match.group(1)
|
|
105
|
+
title = title_match.group(1)
|
|
106
|
+
|
|
107
|
+
# Estrae l'episodio dal titolo (es: "Nome Anime Ep 9")
|
|
108
|
+
ep = "0"
|
|
109
|
+
ep_match = re.search(r' Ep[\.\s]+([^"]+)$', title)
|
|
110
|
+
if ep_match:
|
|
111
|
+
ep = ep_match.group(1).strip()
|
|
112
|
+
name = title[: ep_match.start()].strip()
|
|
113
|
+
else:
|
|
114
|
+
name = title
|
|
115
|
+
|
|
116
|
+
parts = url.strip("/").split("/")
|
|
117
|
+
if len(parts) >= 3:
|
|
118
|
+
anime_slug = parts[1]
|
|
119
|
+
player_id = parts[2]
|
|
120
|
+
anime_ref = f"{self.BASE_URL}/play/{anime_slug}"
|
|
121
|
+
ep_ref = player_id
|
|
122
|
+
else:
|
|
123
|
+
anime_ref = self.BASE_URL + url
|
|
124
|
+
ep_ref = self.BASE_URL + url
|
|
125
|
+
|
|
126
|
+
anime = Anime(unescape(name), anime_ref, ep)
|
|
127
|
+
anime.update_episodes({ep: ep_ref}, specials=specials)
|
|
128
|
+
animes.append(anime)
|
|
129
|
+
|
|
130
|
+
BLOCK_SIZE = 45
|
|
131
|
+
filter_keys = ["a", "s", "d", "t"]
|
|
132
|
+
multiplier = filter_keys.index(filter[0])
|
|
133
|
+
|
|
134
|
+
start = multiplier * BLOCK_SIZE
|
|
135
|
+
end = (multiplier + 1) * BLOCK_SIZE if multiplier < len(filter_keys) else None
|
|
136
|
+
|
|
137
|
+
return animes[start:end]
|
|
138
|
+
|
|
139
|
+
def _episodes(self, anime: Anime):
|
|
140
|
+
html = self._get_html(anime.ref)
|
|
141
|
+
episodes_url = dict[str, str]()
|
|
142
|
+
|
|
143
|
+
# Estrae i tag <a> in modo super stabile limitandosi agli elementi con classe "episode"
|
|
144
|
+
for a_tag in re.findall(r'<li class="episode">[\s\n]*(<a\s+[^>]+>)', html):
|
|
145
|
+
id_match = re.search(r'data-id="([^"]+)"', a_tag)
|
|
146
|
+
num_match = re.search(r'data-num="([^"]+)"', a_tag)
|
|
147
|
+
if id_match and num_match:
|
|
148
|
+
episodes_url[num_match.group(1)] = id_match.group(1)
|
|
149
|
+
return episodes_url
|
|
150
|
+
|
|
151
|
+
def _episode_link(self, anime: Anime, episode: Anime.Episode) -> str:
|
|
152
|
+
player_id = episode.ref
|
|
153
|
+
if "play/" in player_id:
|
|
154
|
+
player_id = player_id.split("/")[-1]
|
|
155
|
+
|
|
156
|
+
api_url = f"{self.BASE_URL}/api/episode/serverPlayerAnimeWorld?id={player_id}"
|
|
157
|
+
response = self.Client.get(api_url)
|
|
158
|
+
response.raise_for_status()
|
|
159
|
+
|
|
160
|
+
source_match = re.search(r'<source src="([^"]+)"', response.text)
|
|
161
|
+
if not source_match:
|
|
162
|
+
raise ValueError("Errore nel parsing della pagina dell'episodio")
|
|
163
|
+
return source_match.group(1)
|
|
164
|
+
|
|
165
|
+
def _info_anime(self, anime: Anime):
|
|
166
|
+
html = self._get_html(anime.ref)
|
|
167
|
+
|
|
168
|
+
res = re.search(r'<a.*id="anilist-button".*href="\D*(\d*)"', html)
|
|
169
|
+
anilist_id = int(res.group(1)) if res else 0
|
|
170
|
+
|
|
171
|
+
temp = re.findall(
|
|
172
|
+
r'<dt>(.*?):</dt>[\n\s]*<dd(?: class="[^"]*")?>((?:.|\n)+?)</dd>', html
|
|
173
|
+
)
|
|
174
|
+
plot_match = re.search(r'<div class="desc">((?:.|\n)+?)</div>', html)
|
|
175
|
+
if plot_match:
|
|
176
|
+
temp.append(("Trama", plot_match.group(1)))
|
|
177
|
+
|
|
178
|
+
info = dict[str, str]()
|
|
179
|
+
status = AnimeStatus.UNKNOWN
|
|
180
|
+
for key, value in temp:
|
|
181
|
+
key, value = key.strip(), value.strip()
|
|
182
|
+
if key == "Stato":
|
|
183
|
+
if match := re.search(r'status=(\d+)"', value):
|
|
184
|
+
status_val = int(match.group(1))
|
|
185
|
+
if status_val < len(AnimeStatus):
|
|
186
|
+
status = list(AnimeStatus)[status_val]
|
|
187
|
+
value = re.sub(r"[\s\n]+", " ", re.sub(r"<.*?>", "", value)).strip()
|
|
188
|
+
info[key] = unescape(value)
|
|
189
|
+
|
|
190
|
+
anime.set_info(anilist_id, status, info)
|
|
191
|
+
|
|
192
|
+
def _parse_info(self, elem: dict) -> tuple[int, AnimeStatus, dict[str, str]]:
|
|
193
|
+
"""Estrae e mappa i metadati ritornati dal JSON dell'API di ricerca di AnimeWorld."""
|
|
194
|
+
anilist_id = elem.get("anilistId", 0)
|
|
195
|
+
state_val = elem.get("stateName", "Sconosciuto")
|
|
196
|
+
status = AnimeStatus.UNKNOWN
|
|
197
|
+
if state_val == "Finito":
|
|
198
|
+
status = AnimeStatus.FINISHED
|
|
199
|
+
elif state_val == "In corso":
|
|
200
|
+
status = AnimeStatus.ONGOING
|
|
201
|
+
elif state_val == "Non rilasciato":
|
|
202
|
+
status = AnimeStatus.NOT_RELEASED
|
|
203
|
+
|
|
204
|
+
info = {
|
|
205
|
+
"Categoria": "Anime" if elem.get("animeType") == "0" else "Movie",
|
|
206
|
+
"Audio": "Italiano" if elem.get("dub") == "1" else "Giapponese",
|
|
207
|
+
"Data di Uscita": elem.get("release") or elem.get("year", ""),
|
|
208
|
+
"Studio": elem.get("studio", ""),
|
|
209
|
+
"Genere": ", ".join(
|
|
210
|
+
cat.get("name", "") for cat in elem.get("categories", [])
|
|
211
|
+
),
|
|
212
|
+
"Voto": f"{elem.get('malVote')}" if elem.get("malVote") else "Sconosciuto",
|
|
213
|
+
"Episodi": f"{elem.get('episodes', '0')}",
|
|
214
|
+
"Stato": state_val,
|
|
215
|
+
"Trama": elem.get("story", ""),
|
|
216
|
+
}
|
|
217
|
+
return int(anilist_id) if anilist_id else 0, status, info
|
|
@@ -12,7 +12,7 @@ class LocalProvider(Provider):
|
|
|
12
12
|
- info_anime: carica info dalla cronologia
|
|
13
13
|
"""
|
|
14
14
|
def __init__(self, path, history: list[Anime]):
|
|
15
|
-
|
|
15
|
+
self.BASE_URL = path
|
|
16
16
|
self.history = history
|
|
17
17
|
|
|
18
18
|
def _search(self, input: str) -> list[Anime]:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
|
-
from
|
|
2
|
+
from curses import error
|
|
3
|
+
from httpx import Client, HTTPError # , AsyncClient
|
|
3
4
|
from ..anime import Anime
|
|
4
5
|
from .. import utilities as ut
|
|
5
6
|
|
|
@@ -8,6 +9,7 @@ def error_handler(relink=False):
|
|
|
8
9
|
"""
|
|
9
10
|
Decoratore per gestire gli errori delle funzioni.
|
|
10
11
|
"""
|
|
12
|
+
|
|
11
13
|
def decorator(func):
|
|
12
14
|
def wrapper(self, *args, **kwargs):
|
|
13
15
|
try:
|
|
@@ -15,11 +17,17 @@ def error_handler(relink=False):
|
|
|
15
17
|
except Exception as e:
|
|
16
18
|
if relink and isinstance(e, HTTPError):
|
|
17
19
|
return update_link(self, func, *args, **kwargs)
|
|
18
|
-
ut.console.print(
|
|
19
|
-
|
|
20
|
+
ut.console.print(
|
|
21
|
+
f"Errore {e.__class__.__name__} durante {func.__name__}: {e}",
|
|
22
|
+
style="error",
|
|
23
|
+
)
|
|
24
|
+
exit(1)
|
|
25
|
+
|
|
20
26
|
return wrapper
|
|
27
|
+
|
|
21
28
|
return decorator
|
|
22
29
|
|
|
30
|
+
|
|
23
31
|
def update_link(self, callback, anime: Anime, episode: Anime.Episode | None = None):
|
|
24
32
|
"""
|
|
25
33
|
Gestisce il caso in cui il riferimento dell'anime o all'episodio non è più valido
|
|
@@ -30,9 +38,11 @@ def update_link(self, callback, anime: Anime, episode: Anime.Episode | None = No
|
|
|
30
38
|
return callback(self, anime, anime.episode(episode.num))
|
|
31
39
|
else:
|
|
32
40
|
ut.console.print("Il link dell'anime è stato cambiato", style="warning")
|
|
33
|
-
res: list[Anime] = self.search(anime.name)
|
|
34
|
-
if
|
|
35
|
-
|
|
41
|
+
res: list[Anime] | None = self.search(anime.name)
|
|
42
|
+
if not res:
|
|
43
|
+
ut.console.print(f"Errore o nessun risultato durante la ricerca di {anime.name} su {self.__class__.__name__}", style="error")
|
|
44
|
+
ut.console.print("Cercarlo manualmente", style="highlight")
|
|
45
|
+
exit(1)
|
|
36
46
|
anime.ref = res[0].ref
|
|
37
47
|
return callback(self, anime)
|
|
38
48
|
|
|
@@ -45,20 +55,23 @@ class Provider(ABC):
|
|
|
45
55
|
_url (str): l'url del sito del provider.
|
|
46
56
|
_headers (dict): gli headers da utilizzare per le richieste HTTP.
|
|
47
57
|
"""
|
|
48
|
-
|
|
58
|
+
|
|
59
|
+
def __init__(self, url: str, client: Client | None = None):
|
|
49
60
|
"""
|
|
50
61
|
Costruisce un'instanza di Provider.
|
|
51
62
|
|
|
52
63
|
Args:
|
|
53
64
|
url (str): l'url del sito del provider.
|
|
65
|
+
client (Client | None): client HTTP opzionale da iniettare (utile per testing).
|
|
54
66
|
"""
|
|
55
67
|
self.BASE_URL = url
|
|
56
|
-
self.Client = Client(
|
|
68
|
+
self.Client = client or Client(
|
|
57
69
|
headers={
|
|
58
|
-
|
|
70
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
|
|
71
|
+
"Connection": "close"
|
|
59
72
|
},
|
|
60
73
|
follow_redirects=True,
|
|
61
|
-
timeout=10
|
|
74
|
+
timeout=10,
|
|
62
75
|
)
|
|
63
76
|
|
|
64
77
|
@error_handler(relink=False)
|
|
@@ -131,8 +144,7 @@ class Provider(ABC):
|
|
|
131
144
|
anime (Anime): l'anime di riferimento.
|
|
132
145
|
"""
|
|
133
146
|
anime.update_episodes(
|
|
134
|
-
self._episodes(anime),
|
|
135
|
-
ut.config_data["general"]["specials"]
|
|
147
|
+
self._episodes(anime), ut.config_data["general"]["specials"]
|
|
136
148
|
)
|
|
137
149
|
|
|
138
150
|
@abstractmethod
|