anime-sama-cli-api 0.4.0__py3-none-any.whl
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.
- anime_sama_api/__init__.py +52 -0
- anime_sama_api/catalogue.py +252 -0
- anime_sama_api/cli/__main__.py +80 -0
- anime_sama_api/cli/config.py +119 -0
- anime_sama_api/cli/config.toml +35 -0
- anime_sama_api/cli/downloader.py +323 -0
- anime_sama_api/cli/error_handling.py +66 -0
- anime_sama_api/cli/internal_player.py +56 -0
- anime_sama_api/cli/play_menu.py +61 -0
- anime_sama_api/cli/utils.py +142 -0
- anime_sama_api/episode.py +207 -0
- anime_sama_api/langs.py +76 -0
- anime_sama_api/link_resolver.py +187 -0
- anime_sama_api/season.py +226 -0
- anime_sama_api/top_level.py +376 -0
- anime_sama_api/utils.py +54 -0
- anime_sama_cli_api-0.4.0.dist-info/METADATA +103 -0
- anime_sama_cli_api-0.4.0.dist-info/RECORD +21 -0
- anime_sama_cli_api-0.4.0.dist-info/WHEEL +4 -0
- anime_sama_cli_api-0.4.0.dist-info/entry_points.txt +2 -0
- anime_sama_cli_api-0.4.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Generator, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse, urlsplit
|
|
7
|
+
|
|
8
|
+
from httpx import HTTPError
|
|
9
|
+
|
|
10
|
+
from .langs import Lang, LangId, flags, id2lang, lang2ids
|
|
11
|
+
from .link_resolver import LinkError, RateLimitedError, validate_url
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Players(list[str]):
|
|
17
|
+
def __init__(self, *args: Sequence[Any], **kwargs: dict[Any, Any]):
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
self.swapPlayers()
|
|
20
|
+
for index, _ in enumerate(self):
|
|
21
|
+
self[index] = self[index].replace("vidmoly.to", "vidmoly.net")
|
|
22
|
+
|
|
23
|
+
def swapPlayers(self) -> None:
|
|
24
|
+
if len(self) < 2:
|
|
25
|
+
return
|
|
26
|
+
self[0], self[1] = (self[1], self[0])
|
|
27
|
+
|
|
28
|
+
def sort_and_filter(
|
|
29
|
+
self, prefer_players: list[str], ban_players: list[str]
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
|
|
32
|
+
def ban_filter(player: str) -> bool:
|
|
33
|
+
player_hostname = urlparse(player).hostname
|
|
34
|
+
if player_hostname is None:
|
|
35
|
+
return False
|
|
36
|
+
return player_hostname in ban_players
|
|
37
|
+
|
|
38
|
+
def key(player: str) -> int:
|
|
39
|
+
player_hostname = urlparse(player).hostname
|
|
40
|
+
if player_hostname is None:
|
|
41
|
+
return 0
|
|
42
|
+
try:
|
|
43
|
+
return prefer_players.index(player_hostname) - len(prefer_players)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
return sorted([player for player in self if not ban_filter(player)], key=key)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Languages(dict[LangId, Players]):
|
|
51
|
+
def __init__(self, *args: dict[LangId, Players], **kargs: Players) -> None:
|
|
52
|
+
super().__init__(*args, **kargs)
|
|
53
|
+
if not self:
|
|
54
|
+
logger.warning("No player available for %s", self)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def availables(self) -> dict[Lang, list[Players]]:
|
|
58
|
+
availables: dict[Lang, list[Players]] = {}
|
|
59
|
+
for lang_id, players in self.items():
|
|
60
|
+
if availables.get(id2lang[lang_id]) is None:
|
|
61
|
+
availables[id2lang[lang_id]] = []
|
|
62
|
+
availables[id2lang[lang_id]].append(players)
|
|
63
|
+
return availables
|
|
64
|
+
|
|
65
|
+
def consume_player(self, prefer_languages, prefer_players, ban_players):
|
|
66
|
+
available = self.availables
|
|
67
|
+
seen_urls = set()
|
|
68
|
+
for language in ordered_languages(prefer_languages, available):
|
|
69
|
+
for players in available.get(language, []):
|
|
70
|
+
for url in players.sort_and_filter(prefer_players, ban_players):
|
|
71
|
+
if url not in seen_urls:
|
|
72
|
+
seen_urls.add(url)
|
|
73
|
+
yield url
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Episode:
|
|
78
|
+
languages: Languages
|
|
79
|
+
series_name: str = ""
|
|
80
|
+
season_name: str = ""
|
|
81
|
+
_name: str = ""
|
|
82
|
+
index: int = 1
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
return self._name.strip()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def fancy_name(self) -> str:
|
|
90
|
+
return f"{self._name.lstrip()} " + " ".join(
|
|
91
|
+
flags[lang] for lang in self.languages.availables if lang != "VOSTFR"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def season_number(self) -> int:
|
|
96
|
+
match_season_number = re.search("\\d+", self.season_name)
|
|
97
|
+
return int(match_season_number.group(0)) if match_season_number else 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def long_name(self) -> str:
|
|
101
|
+
return f"{self.season_name} - {self.name}"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def short_name(self) -> str:
|
|
105
|
+
return f"{self.series_name} S{self.season_number:02}E{self.index:02}"
|
|
106
|
+
|
|
107
|
+
def __str__(self) -> str:
|
|
108
|
+
return self.fancy_name
|
|
109
|
+
|
|
110
|
+
def consume_player(
|
|
111
|
+
self,
|
|
112
|
+
prefer_languages: list[Lang],
|
|
113
|
+
prefer_players: list[str] | None = None,
|
|
114
|
+
ban_players: list[str] | None = None,
|
|
115
|
+
) -> Generator[str]:
|
|
116
|
+
if prefer_players is None:
|
|
117
|
+
prefer_players = []
|
|
118
|
+
if ban_players is None:
|
|
119
|
+
ban_players = []
|
|
120
|
+
yield from self.languages.consume_player(
|
|
121
|
+
prefer_languages, prefer_players, ban_players
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def best(self, prefer_languages: list[Lang]) -> str | None:
|
|
125
|
+
try:
|
|
126
|
+
return next(self.consume_player(prefer_languages))
|
|
127
|
+
except StopIteration:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
async def aconsume_player(self, prefer_languages=None, ban_players=None):
|
|
131
|
+
"""Yield resolved URLs on demand, suitable for the current HTML/API backend."""
|
|
132
|
+
async for url in iter_resolved_players(
|
|
133
|
+
self,
|
|
134
|
+
["VOSTFR"] if prefer_languages is None else prefer_languages,
|
|
135
|
+
ban_players or [],
|
|
136
|
+
):
|
|
137
|
+
yield url
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class DeferredPlayers(Players):
|
|
141
|
+
def __init__(self, references, resolver):
|
|
142
|
+
list.__init__(self, references)
|
|
143
|
+
self.resolver = resolver
|
|
144
|
+
|
|
145
|
+
def sort_and_filter(self, prefer_players, ban_players):
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"Liens différés : utiliser Episode.aconsume_player() ou la CLI."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def candidates_for(episode, prefer_languages):
|
|
152
|
+
order = ordered_languages(prefer_languages)
|
|
153
|
+
seen = set()
|
|
154
|
+
for language in order:
|
|
155
|
+
for lang_id, players in episode.languages.items():
|
|
156
|
+
if id2lang[lang_id] != language:
|
|
157
|
+
continue
|
|
158
|
+
for ref in players:
|
|
159
|
+
owner = (
|
|
160
|
+
id(players.resolver)
|
|
161
|
+
if isinstance(players, DeferredPlayers)
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
key = (owner, ref)
|
|
165
|
+
if ref and key not in seen:
|
|
166
|
+
seen.add(key)
|
|
167
|
+
yield (language, players, ref)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def resolve_reference(players, reference):
|
|
171
|
+
if isinstance(players, DeferredPlayers):
|
|
172
|
+
return await players.resolver.resolve(reference)
|
|
173
|
+
if reference.startswith("lien:"):
|
|
174
|
+
raise LinkError("Référence sans contexte de résolution")
|
|
175
|
+
return validate_url(reference)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def iter_resolved_players(
|
|
179
|
+
episode, prefer_languages, ban_players=(), resolve=None
|
|
180
|
+
):
|
|
181
|
+
resolver = resolve or resolve_reference
|
|
182
|
+
seen_urls = set()
|
|
183
|
+
bans = set(ban_players)
|
|
184
|
+
for language, players, reference in candidates_for(episode, prefer_languages):
|
|
185
|
+
try:
|
|
186
|
+
url = await resolver(players, reference)
|
|
187
|
+
except RateLimitedError:
|
|
188
|
+
raise
|
|
189
|
+
except (LinkError, HTTPError) as exc:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"%s / %s : résolution échouée (%s), candidat suivant",
|
|
192
|
+
episode.name,
|
|
193
|
+
language,
|
|
194
|
+
type(exc).__name__,
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
if not url or url in seen_urls:
|
|
198
|
+
continue
|
|
199
|
+
seen_urls.add(url)
|
|
200
|
+
if urlsplit(url).hostname in bans:
|
|
201
|
+
continue
|
|
202
|
+
yield url
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def ordered_languages(prefer_languages, available=()):
|
|
206
|
+
"""Preserve configured priorities, then append fallback languages once."""
|
|
207
|
+
return list(dict.fromkeys([*prefer_languages, *lang2ids, *available]))
|
anime_sama_api/langs.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
Lang = Literal["VASTFR", "VCN", "VF", "VJSTFR", "VKR", "VQC", "VOSTFR"]
|
|
4
|
+
LangId = Literal["va", "vcn", "vf", "vf1", "vf2", "vj", "vkr", "vqc", "vostfr"]
|
|
5
|
+
FlagId = Literal["cn", "qc", "en", "pal", "kr", "fr", "jp"]
|
|
6
|
+
|
|
7
|
+
lang2ids: dict[Lang, list[LangId]] = {
|
|
8
|
+
"VOSTFR": ["vostfr"],
|
|
9
|
+
"VASTFR": ["va"],
|
|
10
|
+
"VCN": ["vcn"],
|
|
11
|
+
"VF": ["vf", "vf1", "vf2"],
|
|
12
|
+
"VJSTFR": ["vj"],
|
|
13
|
+
"VKR": ["vkr"],
|
|
14
|
+
"VQC": ["vqc"],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
id2lang: dict[LangId, Lang] = {
|
|
18
|
+
lang_id: lang for lang, langs_id in lang2ids.items() for lang_id in langs_id
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
flags: dict[Lang | LangId, str] = {
|
|
22
|
+
"VOSTFR": "",
|
|
23
|
+
"VASTFR": "🇬🇧",
|
|
24
|
+
"VCN": "🇨🇳",
|
|
25
|
+
"VF": "🇫🇷",
|
|
26
|
+
"VJSTFR": "🇯🇵",
|
|
27
|
+
"VKR": "🇰🇷",
|
|
28
|
+
"VQC": "🏴",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
flagid2lang: dict[FlagId, Lang] = {
|
|
32
|
+
"cn": "VCN",
|
|
33
|
+
"qc": "VQC",
|
|
34
|
+
"en": "VASTFR",
|
|
35
|
+
"kr": "VKR",
|
|
36
|
+
"fr": "VF",
|
|
37
|
+
"jp": "VJSTFR",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for language, language_ids in lang2ids.items():
|
|
41
|
+
for lang_id in language_ids:
|
|
42
|
+
flags[lang_id] = flags[language]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
import asyncio
|
|
47
|
+
import re
|
|
48
|
+
from pprint import pprint
|
|
49
|
+
|
|
50
|
+
import httpx
|
|
51
|
+
|
|
52
|
+
from anime_sama_api.top_level import AnimeSama
|
|
53
|
+
|
|
54
|
+
SCRIPT_VIDEO_URL = "https://anime-sama.si/js/contenu/script_videos.js"
|
|
55
|
+
page = httpx.get(SCRIPT_VIDEO_URL).text
|
|
56
|
+
langs = {}
|
|
57
|
+
|
|
58
|
+
matches = re.findall(r"if\((.+)\){langue = \"(.+)\";}", page)
|
|
59
|
+
for match in matches:
|
|
60
|
+
langs[match[1]] = match[0].split('"')[1::2]
|
|
61
|
+
|
|
62
|
+
pprint(langs)
|
|
63
|
+
|
|
64
|
+
async def main() -> None:
|
|
65
|
+
async for catalogue in AnimeSama("https://anime-sama.to/").catalogues_iter():
|
|
66
|
+
if await catalogue.seasons():
|
|
67
|
+
break
|
|
68
|
+
else:
|
|
69
|
+
raise RuntimeError
|
|
70
|
+
|
|
71
|
+
vostfr_url = (await catalogue.seasons())[0].url + "vostfr/"
|
|
72
|
+
response = await catalogue.client.get(vostfr_url)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
print(set(re.findall(r"src=\".+flag_(.+?)\.png\"", response.text)))
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Inline episode references and Anime-Sama link API. No JavaScript execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LinkError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BddScriptParser(HTMLParser):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__(convert_charrefs=False)
|
|
18
|
+
self.active = False
|
|
19
|
+
self.parts = []
|
|
20
|
+
|
|
21
|
+
def handle_starttag(self, tag, attrs):
|
|
22
|
+
if tag.lower() == "script":
|
|
23
|
+
attributes = dict(attrs)
|
|
24
|
+
self.active = attributes.get("data-source") == "bdd"
|
|
25
|
+
|
|
26
|
+
def handle_data(self, data):
|
|
27
|
+
if self.active:
|
|
28
|
+
self.parts.append(data)
|
|
29
|
+
|
|
30
|
+
def handle_endtag(self, tag):
|
|
31
|
+
if tag.lower() == "script":
|
|
32
|
+
self.active = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_bdd_script(html):
|
|
36
|
+
parser = BddScriptParser()
|
|
37
|
+
parser.feed(html)
|
|
38
|
+
|
|
39
|
+
source = "\n".join(parser.parts)
|
|
40
|
+
|
|
41
|
+
if not source.strip():
|
|
42
|
+
raise LinkError('Script <script data-source="bdd"> introuvable.')
|
|
43
|
+
|
|
44
|
+
return source
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RateLimitedError(LinkError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PageData:
|
|
53
|
+
token: str
|
|
54
|
+
players: dict[int, list[str]]
|
|
55
|
+
final_order: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_page(html):
|
|
59
|
+
source = extract_bdd_script(html)
|
|
60
|
+
token = re.search(
|
|
61
|
+
r"\b(?:var|let|const)\s+epsJeton\s*=\s*([\"'])(.*?)\1", source, re.DOTALL
|
|
62
|
+
)
|
|
63
|
+
players = {}
|
|
64
|
+
for match in re.finditer(
|
|
65
|
+
r"\b(?:var|let|const)\s+eps(\d+)\s*=\s*(\[[^;]*?\])\s*;", source, re.DOTALL
|
|
66
|
+
):
|
|
67
|
+
try:
|
|
68
|
+
values = json.loads(match[2])
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise LinkError("Unsupported episode array syntax (expected JSON)") from exc
|
|
71
|
+
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
|
72
|
+
raise LinkError("Invalid episode array")
|
|
73
|
+
players[int(match[1])] = values
|
|
74
|
+
if not players:
|
|
75
|
+
raise LinkError(
|
|
76
|
+
"No inline epsN arrays found; page unavailable or format changed"
|
|
77
|
+
)
|
|
78
|
+
needs_token = any(v.startswith("lien:") for a in players.values() for v in a)
|
|
79
|
+
if needs_token and not token:
|
|
80
|
+
raise LinkError("epsJeton missing")
|
|
81
|
+
final = bool(re.search(r"\bepsOrdreFinal\s*=\s*true\b", source))
|
|
82
|
+
return PageData(token[2] if token else "", players, final)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_url(value):
|
|
86
|
+
if not isinstance(value, str):
|
|
87
|
+
raise LinkError("API field u must be a string")
|
|
88
|
+
parsed = urlsplit(value)
|
|
89
|
+
if (
|
|
90
|
+
parsed.scheme not in ("https", "http")
|
|
91
|
+
or not parsed.hostname
|
|
92
|
+
or parsed.username
|
|
93
|
+
or parsed.password
|
|
94
|
+
):
|
|
95
|
+
raise LinkError("Invalid player URL")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LinkResolver:
|
|
100
|
+
def __init__(self, client, page_url):
|
|
101
|
+
self.client = client
|
|
102
|
+
self.page_url = page_url
|
|
103
|
+
self.data = None
|
|
104
|
+
self.cache = {}
|
|
105
|
+
self.min_interval = 0.0
|
|
106
|
+
self._last_request = 0.0
|
|
107
|
+
self._refresh_lock = asyncio.Lock()
|
|
108
|
+
|
|
109
|
+
async def load(self):
|
|
110
|
+
response = await self.client.get(self.page_url, follow_redirects=True)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
self.page_url = str(response.url)
|
|
113
|
+
self.data = parse_page(response.text)
|
|
114
|
+
self.cache.clear()
|
|
115
|
+
return response.text
|
|
116
|
+
|
|
117
|
+
async def resolve(self, reference):
|
|
118
|
+
if not reference:
|
|
119
|
+
return ""
|
|
120
|
+
if not reference.startswith("lien:"):
|
|
121
|
+
return validate_url(reference)
|
|
122
|
+
identifier = reference[5:]
|
|
123
|
+
if not identifier.isdecimal():
|
|
124
|
+
raise LinkError("Invalid lien ID")
|
|
125
|
+
if self.data is None:
|
|
126
|
+
await self.load()
|
|
127
|
+
refreshed = False
|
|
128
|
+
for attempt in range(3):
|
|
129
|
+
key = (self.data.token, identifier)
|
|
130
|
+
if key in self.cache:
|
|
131
|
+
return self.cache[key]
|
|
132
|
+
try:
|
|
133
|
+
loop = asyncio.get_running_loop()
|
|
134
|
+
wait = self.min_interval - (loop.time() - self._last_request)
|
|
135
|
+
if wait > 0:
|
|
136
|
+
await asyncio.sleep(wait)
|
|
137
|
+
self._last_request = loop.time()
|
|
138
|
+
response = await self.client.get(
|
|
139
|
+
urljoin(self.page_url, "/api/lien.php"),
|
|
140
|
+
params={"j": self.data.token, "l": identifier},
|
|
141
|
+
headers={"Referer": self.page_url},
|
|
142
|
+
follow_redirects=False,
|
|
143
|
+
)
|
|
144
|
+
if response.status_code == 429:
|
|
145
|
+
retry_after = response.headers.get("Retry-After")
|
|
146
|
+
|
|
147
|
+
detail = (
|
|
148
|
+
f"Retry-After reçu : {retry_after}."
|
|
149
|
+
if retry_after
|
|
150
|
+
else "Aucun délai Retry-After fourni par le serveur."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raise RateLimitedError(
|
|
154
|
+
"Anime-Sama limite actuellement les requêtes. " + detail
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
try:
|
|
159
|
+
payload = response.json()
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
raise LinkError(
|
|
162
|
+
"API did not return JSON (possibly a verification page)"
|
|
163
|
+
) from exc
|
|
164
|
+
if not isinstance(payload, dict):
|
|
165
|
+
raise LinkError("API response must be an object")
|
|
166
|
+
if payload.get("u"):
|
|
167
|
+
value = validate_url(payload["u"])
|
|
168
|
+
self.cache[key] = value
|
|
169
|
+
return value
|
|
170
|
+
error = payload.get("erreur", "missing u")
|
|
171
|
+
if error == "expire" and not refreshed:
|
|
172
|
+
refreshed = True
|
|
173
|
+
async with self._refresh_lock:
|
|
174
|
+
if self.data.token == key[0]:
|
|
175
|
+
await self.load()
|
|
176
|
+
continue
|
|
177
|
+
if error in ("absent", "expire"):
|
|
178
|
+
raise LinkError("Terminal API error: " + str(error))
|
|
179
|
+
if attempt == 2:
|
|
180
|
+
raise LinkError("API error: " + str(error))
|
|
181
|
+
except LinkError:
|
|
182
|
+
raise
|
|
183
|
+
except Exception:
|
|
184
|
+
if attempt == 2:
|
|
185
|
+
raise
|
|
186
|
+
await asyncio.sleep((1, 3)[min(attempt, 1)])
|
|
187
|
+
raise LinkError("Resolution failed after token refresh")
|
anime_sama_api/season.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from ast import literal_eval
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from functools import reduce
|
|
6
|
+
from typing import get_args
|
|
7
|
+
|
|
8
|
+
from httpx import AsyncClient
|
|
9
|
+
|
|
10
|
+
from anime_sama_api.episode import DeferredPlayers
|
|
11
|
+
|
|
12
|
+
from .episode import Episode, Languages, Players
|
|
13
|
+
from .langs import LangId, flagid2lang, lang2ids
|
|
14
|
+
from .link_resolver import LinkError, LinkResolver, RateLimitedError, parse_page
|
|
15
|
+
from .utils import remove_some_js_comments, split_and_strip
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EpisodeDataUnavailableError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SeasonLangPage:
|
|
26
|
+
lang_id: LangId
|
|
27
|
+
html: str = ""
|
|
28
|
+
episodes_js: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Season:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
url: str,
|
|
35
|
+
name: str = "",
|
|
36
|
+
series_name: str = "",
|
|
37
|
+
client: AsyncClient | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.url = url
|
|
40
|
+
self.site_url = "/".join(url.split("/")[:3]) + "/"
|
|
41
|
+
self.name = name or url.split("/")[-2]
|
|
42
|
+
self.series_name = series_name or url.split("/")[-3]
|
|
43
|
+
self.client = client or AsyncClient()
|
|
44
|
+
|
|
45
|
+
async def get_all_pages(self):
|
|
46
|
+
pages = []
|
|
47
|
+
self._api_rows = {}
|
|
48
|
+
for lang_id in get_args(LangId):
|
|
49
|
+
page_url = self.url.rstrip("/") + "/" + lang_id + "/"
|
|
50
|
+
response = await self.client.get(page_url, follow_redirects=True)
|
|
51
|
+
if response.status_code == 404:
|
|
52
|
+
continue
|
|
53
|
+
if response.status_code == 429:
|
|
54
|
+
delay = response.headers.get("Retry-After", "non fourni")
|
|
55
|
+
raise RateLimitedError(
|
|
56
|
+
"Limitation lors du chargement HTML. Retry-After : " + delay
|
|
57
|
+
)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
try:
|
|
60
|
+
data = parse_page(response.text)
|
|
61
|
+
except LinkError as exc:
|
|
62
|
+
logger.warning("Page langue ignorée (%s) : %s", page_url, exc)
|
|
63
|
+
continue
|
|
64
|
+
resolver = LinkResolver(self.client, str(response.url))
|
|
65
|
+
resolver.data = data
|
|
66
|
+
arrays = [data.players[n] for n in sorted(data.players)]
|
|
67
|
+
rows = []
|
|
68
|
+
for index in range(max(map(len, arrays), default=0)):
|
|
69
|
+
refs = [a[index] if index < len(a) else "" for a in arrays]
|
|
70
|
+
if not data.final_order and len(refs) > 1:
|
|
71
|
+
refs[0], refs[1] = (refs[1], refs[0])
|
|
72
|
+
rows.append(DeferredPlayers([r for r in refs if r], resolver))
|
|
73
|
+
self._api_rows[lang_id] = rows
|
|
74
|
+
pages.append(SeasonLangPage(lang_id=lang_id, html=response.text))
|
|
75
|
+
mapping = {p.lang_id: p for p in pages}
|
|
76
|
+
if "vostfr" in mapping:
|
|
77
|
+
matches = re.findall(
|
|
78
|
+
'src="[^"]*flag_([^"]+?)\\.png".*?<span[^>]*>\\s*VOSTFR\\s*</span>',
|
|
79
|
+
remove_some_js_comments(mapping["vostfr"].html),
|
|
80
|
+
re.DOTALL | re.IGNORECASE,
|
|
81
|
+
)
|
|
82
|
+
if matches and matches[0] in flagid2lang:
|
|
83
|
+
for target in lang2ids[flagid2lang[matches[0]]]:
|
|
84
|
+
if target not in mapping:
|
|
85
|
+
pages.append(replace(mapping["vostfr"], lang_id=target))
|
|
86
|
+
self._api_rows[target] = self._api_rows["vostfr"]
|
|
87
|
+
break
|
|
88
|
+
if not pages:
|
|
89
|
+
raise EpisodeDataUnavailableError(
|
|
90
|
+
"Aucune page avec données bdd exploitable : " + self.url
|
|
91
|
+
)
|
|
92
|
+
return pages
|
|
93
|
+
|
|
94
|
+
def _get_players_from(self, page):
|
|
95
|
+
return self._api_rows[page.lang_id]
|
|
96
|
+
|
|
97
|
+
def _get_episodes_names(
|
|
98
|
+
self, page: SeasonLangPage, number_of_episodes: int, number_of_episodes_max: int
|
|
99
|
+
) -> list[str]:
|
|
100
|
+
functions = re.findall(
|
|
101
|
+
"resetListe\\(\\); *[\\n\\r]+\\t*(.*?)}",
|
|
102
|
+
remove_some_js_comments(page.html),
|
|
103
|
+
re.DOTALL,
|
|
104
|
+
)
|
|
105
|
+
if not functions:
|
|
106
|
+
raise EpisodeDataUnavailableError(
|
|
107
|
+
f"Could not find episode names for {self.series_name} - {self.name} ({self.url + page.lang_id}/)."
|
|
108
|
+
)
|
|
109
|
+
functions = functions[-1]
|
|
110
|
+
functions_list = split_and_strip(functions, (";", "\n"))[:-1]
|
|
111
|
+
|
|
112
|
+
def padding(n: int) -> str:
|
|
113
|
+
return " " * (len(str(number_of_episodes_max)) - len(str(n)))
|
|
114
|
+
|
|
115
|
+
def episode_name_range(*args) -> list[str]:
|
|
116
|
+
return [f"Episode {n}{padding(n)}" for n in range(*args)]
|
|
117
|
+
|
|
118
|
+
episodes_name: list[str] = []
|
|
119
|
+
for function in functions_list:
|
|
120
|
+
if function.startswith("//"):
|
|
121
|
+
continue
|
|
122
|
+
call_start = function.find("(")
|
|
123
|
+
function, args_sting = (
|
|
124
|
+
function[:call_start],
|
|
125
|
+
function[call_start + 1 : -1],
|
|
126
|
+
)
|
|
127
|
+
if args_sting:
|
|
128
|
+
args = literal_eval(node_or_string=args_sting + ",")
|
|
129
|
+
if type(args) is not tuple:
|
|
130
|
+
continue
|
|
131
|
+
else:
|
|
132
|
+
args = ()
|
|
133
|
+
match function:
|
|
134
|
+
case "":
|
|
135
|
+
continue
|
|
136
|
+
case "creerListe":
|
|
137
|
+
if len(args) < 2:
|
|
138
|
+
continue
|
|
139
|
+
episodes_name += episode_name_range(int(args[0]), int(args[1]) + 1)
|
|
140
|
+
case "finirListe" | "finirListeOP":
|
|
141
|
+
if not args:
|
|
142
|
+
break
|
|
143
|
+
episodes_name += episode_name_range(
|
|
144
|
+
int(args[0]),
|
|
145
|
+
int(args[0]) + number_of_episodes - len(episodes_name),
|
|
146
|
+
)
|
|
147
|
+
break
|
|
148
|
+
case "newSP":
|
|
149
|
+
if not args:
|
|
150
|
+
raise NotImplementedError(
|
|
151
|
+
f"Error while parsing 'newSP'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
|
|
152
|
+
)
|
|
153
|
+
episodes_name.append(f"Episode {args[0]}")
|
|
154
|
+
case "newSPF":
|
|
155
|
+
if not args:
|
|
156
|
+
raise NotImplementedError(
|
|
157
|
+
f"Error while parsing 'newSPF'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
|
|
158
|
+
)
|
|
159
|
+
episodes_name.append(args[0])
|
|
160
|
+
case name:
|
|
161
|
+
raise NotImplementedError(
|
|
162
|
+
f"Error cannot parse '{name}'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
|
|
163
|
+
)
|
|
164
|
+
return episodes_name
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _extend_episodes(
|
|
168
|
+
current: list[tuple[str, Languages]],
|
|
169
|
+
new: tuple[SeasonLangPage, list[str], list[Players]],
|
|
170
|
+
) -> list[tuple[str, Languages]]:
|
|
171
|
+
"""
|
|
172
|
+
Extend a list of episodes AKA (name, languages) from a list names and players corresponding
|
|
173
|
+
to a language while preserving the relative order of names.
|
|
174
|
+
This function is intended to be used with reduce.
|
|
175
|
+
"""
|
|
176
|
+
page, names, players_list = new
|
|
177
|
+
fusion = []
|
|
178
|
+
curr_done = 0
|
|
179
|
+
for name_new, players in zip(names, players_list):
|
|
180
|
+
for pos, (name_current, languages) in enumerate(current[curr_done:]):
|
|
181
|
+
if name_new == name_current:
|
|
182
|
+
languages[page.lang_id] = players
|
|
183
|
+
fusion.extend(current[curr_done : curr_done + pos + 1])
|
|
184
|
+
curr_done += pos + 1
|
|
185
|
+
break
|
|
186
|
+
else:
|
|
187
|
+
fusion.append((name_new, Languages({page.lang_id: players})))
|
|
188
|
+
fusion.extend(current[curr_done:])
|
|
189
|
+
return fusion
|
|
190
|
+
|
|
191
|
+
async def episodes(self) -> list[Episode]:
|
|
192
|
+
pages = await self.get_all_pages()
|
|
193
|
+
if not pages:
|
|
194
|
+
raise EpisodeDataUnavailableError(
|
|
195
|
+
f"Could not retrieve episode data for {self.series_name} - {self.name} ({self.url}). The season may be unavailable or Anime-Sama's page structure may have changed."
|
|
196
|
+
)
|
|
197
|
+
players_list = [self._get_players_from(page) for page in pages]
|
|
198
|
+
if not any(players_list):
|
|
199
|
+
raise EpisodeDataUnavailableError(
|
|
200
|
+
f"Could not parse episode players for {self.series_name} - {self.name} ({self.url}). Anime-Sama's page structure may have changed."
|
|
201
|
+
)
|
|
202
|
+
number_of_episodes_max = max(
|
|
203
|
+
len(episodes_page) for episodes_page in players_list
|
|
204
|
+
)
|
|
205
|
+
episodes_names = [
|
|
206
|
+
self._get_episodes_names(page, len(episodes_page), number_of_episodes_max)
|
|
207
|
+
for page, episodes_page in zip(pages, players_list)
|
|
208
|
+
]
|
|
209
|
+
episodes: list[tuple[str, Languages]] = reduce(
|
|
210
|
+
self._extend_episodes, zip(pages, episodes_names, players_list), []
|
|
211
|
+
)
|
|
212
|
+
return [
|
|
213
|
+
Episode(languages, self.series_name, self.name, name, index)
|
|
214
|
+
for index, (name, languages) in enumerate(episodes, start=1)
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return f"Season({self.name!r}, {self.series_name!r})"
|
|
219
|
+
|
|
220
|
+
def __str__(self) -> str:
|
|
221
|
+
return self.name
|
|
222
|
+
|
|
223
|
+
def __eq__(self, value: object) -> bool:
|
|
224
|
+
if not isinstance(value, Season):
|
|
225
|
+
return False
|
|
226
|
+
return self.url == value.url
|