@aphrody/animesama 0.2.0
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.
- package/README.md +176 -0
- package/package.json +36 -0
- package/src/index.ts +1202 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 aphrody-code
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @module bxc/scrapers/animesama
|
|
19
|
+
*
|
|
20
|
+
* Scraper typé et dédié à **anime-sama.to** — catalogue et référencement
|
|
21
|
+
* d'animes francophones. Rétro-ingénierie faite sur les pages réelles
|
|
22
|
+
* (2026-09-03). Toute la couche d'extraction est **purement textuelle** (pas de
|
|
23
|
+
* DOM, pas d'exécution de JS) : elle analyse le HTML rendu côté serveur et les
|
|
24
|
+
* fichiers `episodes.js`, donc elle fonctionne aussi bien sur un miroir
|
|
25
|
+
* persisté que sur une réponse live.
|
|
26
|
+
*
|
|
27
|
+
* ## Anatomie réelle du site
|
|
28
|
+
*
|
|
29
|
+
* - **Fiche d'une œuvre** `/catalogue/<slug>/` :
|
|
30
|
+
* - `<h1 class="…">` → titre, `#titreAlter` → titres alternatifs,
|
|
31
|
+
* `#synopsisText` → synopsis, `.genre-pill` → genres.
|
|
32
|
+
* - `.info-card` (`.info-lbl` / `.info-val`) → État, Année, Épisodes,
|
|
33
|
+
* Studio.
|
|
34
|
+
* - `<meta property="og:image">` / `#coverOeuvre` → jaquette.
|
|
35
|
+
* - Les saisons ne sont **pas** du HTML : elles sont écrites par des appels
|
|
36
|
+
* `panneauAnime("Saison 1", "saison1/vf")` (et `panneauScan(…)` pour les
|
|
37
|
+
* scans) dans un `<script>`, exécutés via `document.write`. C'est la
|
|
38
|
+
* seule source de vérité pour la liste des saisons et des langues.
|
|
39
|
+
* - **Page d'une saison** `/catalogue/<slug>/<saison>/<langue>/` :
|
|
40
|
+
* - charge `episodes.js` (même dossier) et `/js/contenu/videos.js`.
|
|
41
|
+
* - `#titreOeuvre` → titre de l'œuvre, `<title>` → « Œuvre - Saison N ».
|
|
42
|
+
* - un `<script>$(document).ready(…)</script>` en fin de page compose la
|
|
43
|
+
* liste affichée avec `resetListe()`, `creerListe(debut, fin)`,
|
|
44
|
+
* `newSP(numero)`, `newSPF("nom libre")` et `finirListe(debut)`.
|
|
45
|
+
* Le **dernier** `resetListe()` gagne : le bloc par défaut
|
|
46
|
+
* (`resetListe(); finirListe(1);`) est souvent suivi d'un second bloc
|
|
47
|
+
* spécifique aux films. Un gabarit commenté (`/* … *\/`) contient les
|
|
48
|
+
* mêmes appels : il faut retirer les commentaires avant d'analyser.
|
|
49
|
+
* - **`/catalogue/<slug>/<saison>/<langue>/episodes.js`** :
|
|
50
|
+
* - une variable `var epsN = [ 'url', 'url', … ];` par **lecteur**
|
|
51
|
+
* (hébergeur), `N` de 1 à 8. Une case = un épisode, dans l'ordre.
|
|
52
|
+
* - la numérotation n'est ni dense ni ordonnée : on croise `var eps2`
|
|
53
|
+
* déclaré avant `var eps1`, `eps1` absent, des retours à la ligne
|
|
54
|
+
* arbitraires et des virgules traînantes.
|
|
55
|
+
* - `videos.js` **échange** `eps1` et `eps2` à l'affichage ; ce module
|
|
56
|
+
* conserve la numérotation brute du fichier (voir {@link Lecteur.index}).
|
|
57
|
+
* - **Recherche** : `POST /template-php/defaut/fetch.php` avec `query=<texte>`
|
|
58
|
+
* → fragment HTML de `<a class="asn-search-result">`. Alternative en GET :
|
|
59
|
+
* `/catalogue/?search=<texte>&page=N` → cartes `.catalog-card`.
|
|
60
|
+
*
|
|
61
|
+
* ## Hébergeurs rencontrés
|
|
62
|
+
*
|
|
63
|
+
* `ansembed.net`, `lpayer.embed4me.com`, `video.sibnet.ru`, `sendvid.com`,
|
|
64
|
+
* `movearnpre.com`, `oneupload.to`, `s22.anime-sama.fr` (direct mp4),
|
|
65
|
+
* `www.youtube.com/embed`, `www.dailymotion.com/embed`, `vidmoly`, `myvi.top`.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { AnimesamaScraper } from "@aphrody/animesama";
|
|
70
|
+
*
|
|
71
|
+
* const as = new AnimesamaScraper();
|
|
72
|
+
* const fiche = await as.getAnime("inazuma-eleven");
|
|
73
|
+
* console.log(fiche.titre, fiche.saisons.length); // Inazuma Eleven 7
|
|
74
|
+
*
|
|
75
|
+
* const saison = await as.getSaison("inazuma-eleven", "saison1", "vf");
|
|
76
|
+
* console.log(saison.episodes[0].lecteurs[0].url);
|
|
77
|
+
*
|
|
78
|
+
* const source = await as.resoudreLecteur(saison.episodes[0].lecteurs[0]);
|
|
79
|
+
* console.log(source.type, source.url);
|
|
80
|
+
* await as.close();
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import { Browser } from "@aphrody/bxc";
|
|
85
|
+
import {
|
|
86
|
+
classifyMedia,
|
|
87
|
+
extractMediaCandidates,
|
|
88
|
+
extractPoster,
|
|
89
|
+
hostFromUrl,
|
|
90
|
+
normalizeEmbedUrl,
|
|
91
|
+
resolveEmbed,
|
|
92
|
+
resolveVariants,
|
|
93
|
+
unpackPacker,
|
|
94
|
+
type MediaTransport,
|
|
95
|
+
type MediaVariant,
|
|
96
|
+
} from "@aphrody/bxc/media";
|
|
97
|
+
|
|
98
|
+
type AnyPage = Awaited<ReturnType<typeof Browser.newPage>>;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Types publics
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Codes de langue utilisés par les dossiers du site (`…/saison1/vostfr/`). */
|
|
105
|
+
export const LANGUES_ANIMESAMA = [
|
|
106
|
+
"vostfr",
|
|
107
|
+
"vf",
|
|
108
|
+
"va",
|
|
109
|
+
"var",
|
|
110
|
+
"vkr",
|
|
111
|
+
"vcn",
|
|
112
|
+
"vqc",
|
|
113
|
+
"vf1",
|
|
114
|
+
"vf2",
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
/** Langue d'une saison, telle qu'elle apparaît dans l'URL. */
|
|
118
|
+
export type LangueAnimesama = (typeof LANGUES_ANIMESAMA)[number];
|
|
119
|
+
|
|
120
|
+
/** Profils de transport bxc acceptés (identiques aux autres scrapers du dépôt). */
|
|
121
|
+
export type ProfilAnimesama = "static" | "http" | "fast" | "stealth" | "max";
|
|
122
|
+
|
|
123
|
+
/** Une entrée de résultat de recherche ou de carte du catalogue. */
|
|
124
|
+
export interface ResultatRecherche {
|
|
125
|
+
/** Slug de l'œuvre (`"inazuma-eleven"`). */
|
|
126
|
+
slug: string;
|
|
127
|
+
/** Titre affiché. */
|
|
128
|
+
titre: string;
|
|
129
|
+
/** Titres alternatifs, tels qu'imprimés (souvent tronqués sur les cartes). */
|
|
130
|
+
titresAlternatifs: string[];
|
|
131
|
+
/** URL absolue de la fiche. */
|
|
132
|
+
url: string;
|
|
133
|
+
/** Vignette, quand la page en fournit une. */
|
|
134
|
+
image: string | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Une saison (ou un bloc de films / de scans) déclarée sur la fiche. */
|
|
138
|
+
export interface SaisonRef {
|
|
139
|
+
/** Libellé affiché (`"Saison 1"`, `"GO: Chrono Stones"`, `"Film"`). */
|
|
140
|
+
nom: string;
|
|
141
|
+
/** Chemin relatif déclaré (`"saison1/vf"`). */
|
|
142
|
+
chemin: string;
|
|
143
|
+
/** Dossier de saison (`"saison1"`, `"film"`, `"oav"`…). */
|
|
144
|
+
saison: string;
|
|
145
|
+
/** Langue extraite du chemin, `null` si le chemin n'en porte pas. */
|
|
146
|
+
langue: LangueAnimesama | null;
|
|
147
|
+
/** URL absolue de la page de saison. */
|
|
148
|
+
url: string;
|
|
149
|
+
/** `anime` (`panneauAnime`) ou `scan` (`panneauScan`). */
|
|
150
|
+
categorie: "anime" | "scan";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fiche complète d'une œuvre du catalogue. */
|
|
154
|
+
export interface FicheAnime {
|
|
155
|
+
slug: string;
|
|
156
|
+
url: string;
|
|
157
|
+
titre: string;
|
|
158
|
+
titresAlternatifs: string[];
|
|
159
|
+
synopsis: string | null;
|
|
160
|
+
/** Jaquette pleine résolution. */
|
|
161
|
+
image: string | null;
|
|
162
|
+
genres: string[];
|
|
163
|
+
/** État de diffusion tel qu'imprimé (`"Terminé"`, `"En cours"`…). */
|
|
164
|
+
etat: string | null;
|
|
165
|
+
/** Année de première diffusion, quand la fiche l'affiche. */
|
|
166
|
+
annee: number | null;
|
|
167
|
+
/** Nombre d'épisodes annoncé par la fiche (peut différer du réel). */
|
|
168
|
+
nombreEpisodesAnnonce: number | null;
|
|
169
|
+
/** Studios d'animation. */
|
|
170
|
+
studios: string[];
|
|
171
|
+
/** Saisons animées déclarées par `panneauAnime`. */
|
|
172
|
+
saisons: SaisonRef[];
|
|
173
|
+
/** Blocs de scans déclarés par `panneauScan`. */
|
|
174
|
+
scans: SaisonRef[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Un lecteur = une variable `epsN` du fichier `episodes.js`. */
|
|
178
|
+
export interface Lecteur {
|
|
179
|
+
/** Numéro brut de la variable (`eps1` → 1). Non contigu, non trié. */
|
|
180
|
+
index: number;
|
|
181
|
+
/** Nom d'affichage du site (`"Lecteur 1"`), dérivé de l'ordre croissant. */
|
|
182
|
+
nom: string;
|
|
183
|
+
/** Hébergeur dominant du lecteur (`"sibnet"`, `"ansembed"`…). */
|
|
184
|
+
hebergeur: string;
|
|
185
|
+
/** URLs d'embed, une par épisode, dans l'ordre du fichier. */
|
|
186
|
+
urls: string[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** L'URL d'un épisode chez un lecteur donné. */
|
|
190
|
+
export interface LecteurEpisode {
|
|
191
|
+
/** Numéro brut de la variable `epsN`. */
|
|
192
|
+
index: number;
|
|
193
|
+
/** Nom d'affichage du lecteur. */
|
|
194
|
+
nom: string;
|
|
195
|
+
/** Hébergeur normalisé. */
|
|
196
|
+
hebergeur: string;
|
|
197
|
+
/** URL d'embed, normalisée (vidmoly → `.biz`). */
|
|
198
|
+
url: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Un épisode d'une saison, tous lecteurs confondus. */
|
|
202
|
+
export interface EpisodeAnimesama {
|
|
203
|
+
/** Position dans les tableaux `epsN` (0-based). */
|
|
204
|
+
position: number;
|
|
205
|
+
/** Nom affiché par le site (`"Episode 3"`, `"Broly"`…). */
|
|
206
|
+
nom: string;
|
|
207
|
+
/** Numéro d'épisode quand le nom en porte un, sinon `null`. */
|
|
208
|
+
numero: number | null;
|
|
209
|
+
/** Une entrée par lecteur qui couvre cet épisode. */
|
|
210
|
+
lecteurs: LecteurEpisode[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Une saison résolue : lecteurs bruts + épisodes nommés. */
|
|
214
|
+
export interface SaisonAnimesama {
|
|
215
|
+
slug: string;
|
|
216
|
+
saison: string;
|
|
217
|
+
langue: LangueAnimesama;
|
|
218
|
+
url: string;
|
|
219
|
+
/** Titre de l'œuvre tel qu'affiché sur la page de saison. */
|
|
220
|
+
titre: string | null;
|
|
221
|
+
/** Libellé de la saison (`"Saison 1"`), tel qu'injecté par la page. */
|
|
222
|
+
libelle: string | null;
|
|
223
|
+
lecteurs: Lecteur[];
|
|
224
|
+
episodes: EpisodeAnimesama[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Variante d'un master HLS. */
|
|
228
|
+
export interface QualiteMedia {
|
|
229
|
+
label: string;
|
|
230
|
+
url: string;
|
|
231
|
+
resolution?: string;
|
|
232
|
+
bandePassante?: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Résultat de la résolution d'un embed vers un flux direct. */
|
|
236
|
+
export interface SourceResolue {
|
|
237
|
+
hebergeur: string;
|
|
238
|
+
embedUrl: string;
|
|
239
|
+
/** `hls` pour `.m3u8`, `dash` pour `.mpd`, `mp4` pour du progressif, `unknown` sinon. */
|
|
240
|
+
type: "hls" | "dash" | "mp4" | "unknown";
|
|
241
|
+
/** URL média directe, ou `null` si la résolution a échoué. */
|
|
242
|
+
url: string | null;
|
|
243
|
+
/** Image d'aperçu déclarée par le lecteur, quand elle existe. */
|
|
244
|
+
poster: string | null;
|
|
245
|
+
/** Variantes du master HLS, quand elles ont été énumérées. */
|
|
246
|
+
qualites?: QualiteMedia[];
|
|
247
|
+
/** En-têtes nécessaires à la lecture (notamment `Referer`). */
|
|
248
|
+
enTetes: Record<string, string>;
|
|
249
|
+
/** Raison lisible de l'échec quand `url` vaut `null`. */
|
|
250
|
+
erreur: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Une requête HTTP émise par le scraper. */
|
|
254
|
+
export interface RequeteHttp {
|
|
255
|
+
url: string;
|
|
256
|
+
methode?: "GET" | "POST";
|
|
257
|
+
/** Corps déjà encodé (`application/x-www-form-urlencoded` pour la recherche). */
|
|
258
|
+
corps?: string;
|
|
259
|
+
enTetes?: Record<string, string>;
|
|
260
|
+
referer?: string;
|
|
261
|
+
timeoutMs?: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** La réponse minimale dont les analyseurs ont besoin. */
|
|
265
|
+
export interface ReponseHttp {
|
|
266
|
+
status: number;
|
|
267
|
+
corps: string;
|
|
268
|
+
/** URL finale après redirections, quand le transport la connaît. */
|
|
269
|
+
url?: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Transport HTTP injectable. Le remplacer permet de tester le scraper sans
|
|
274
|
+
* réseau, ou de le brancher sur un cache / un miroir.
|
|
275
|
+
*/
|
|
276
|
+
export type TransportHttp = (requete: RequeteHttp) => Promise<ReponseHttp>;
|
|
277
|
+
|
|
278
|
+
/** Options de {@link AnimesamaScraper}. */
|
|
279
|
+
export interface AnimesamaOptions {
|
|
280
|
+
/** Profil de transport bxc. `static` (défaut) est le plus rapide, zéro spawn. */
|
|
281
|
+
profile?: ProfilAnimesama;
|
|
282
|
+
/** Origine du site (défaut `https://anime-sama.to`). */
|
|
283
|
+
baseUrl?: string;
|
|
284
|
+
/** Délai de navigation par requête, en ms (défaut 30000). */
|
|
285
|
+
timeoutMs?: number;
|
|
286
|
+
/** Nombre de reprises sur échec transitoire (défaut 2). */
|
|
287
|
+
retries?: number;
|
|
288
|
+
/**
|
|
289
|
+
* Transport injecté. Quand il est fourni, aucune page bxc n'est ouverte —
|
|
290
|
+
* c'est ce que font les tests.
|
|
291
|
+
*/
|
|
292
|
+
transport?: TransportHttp;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Aides HTML pures
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
const ENTITES_NOMMEES: Record<string, string> = {
|
|
300
|
+
quot: '"',
|
|
301
|
+
amp: "&",
|
|
302
|
+
apos: "'",
|
|
303
|
+
lt: "<",
|
|
304
|
+
gt: ">",
|
|
305
|
+
nbsp: " ",
|
|
306
|
+
hellip: "…",
|
|
307
|
+
laquo: "«",
|
|
308
|
+
raquo: "»",
|
|
309
|
+
eacute: "é",
|
|
310
|
+
egrave: "è",
|
|
311
|
+
agrave: "à",
|
|
312
|
+
ccedil: "ç",
|
|
313
|
+
rsquo: "’",
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Décode les entités HTML numériques et les quelques entités nommées utiles. */
|
|
317
|
+
export function decoderEntites(s: string): string {
|
|
318
|
+
return s
|
|
319
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) =>
|
|
320
|
+
String.fromCodePoint(parseInt(h, 16)),
|
|
321
|
+
)
|
|
322
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
323
|
+
.replace(
|
|
324
|
+
/&([a-z]+);/gi,
|
|
325
|
+
(m, nom) => ENTITES_NOMMEES[nom.toLowerCase()] ?? m,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Retire les balises, décode les entités et normalise les espaces. */
|
|
330
|
+
export function texteBrut(s: string): string {
|
|
331
|
+
return decoderEntites(s.replace(/<[^>]+>/g, " "))
|
|
332
|
+
.replace(/\s+/g, " ")
|
|
333
|
+
.trim();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Dernier segment non vide d'une URL ou d'un chemin. */
|
|
337
|
+
function dernierSegment(url: string): string {
|
|
338
|
+
return (
|
|
339
|
+
url
|
|
340
|
+
.replace(/[?#].*$/, "")
|
|
341
|
+
.replace(/\/+$/, "")
|
|
342
|
+
.split("/")
|
|
343
|
+
.pop() ?? ""
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Lit le `content` d'une `<meta>` par `name` ou `property`. */
|
|
348
|
+
function metaContenu(html: string, cle: string): string | null {
|
|
349
|
+
const echappe = cle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
350
|
+
const direct = new RegExp(
|
|
351
|
+
`<meta[^>]+(?:property|name|itemprop)\\s*=\\s*["']${echappe}["'][^>]*content\\s*=\\s*["']([^"']*)["']`,
|
|
352
|
+
"i",
|
|
353
|
+
).exec(html);
|
|
354
|
+
if (direct) return decoderEntites(direct[1]);
|
|
355
|
+
const inverse = new RegExp(
|
|
356
|
+
`<meta[^>]+content\\s*=\\s*["']([^"']*)["'][^>]*(?:property|name|itemprop)\\s*=\\s*["']${echappe}["']`,
|
|
357
|
+
"i",
|
|
358
|
+
).exec(html);
|
|
359
|
+
return inverse ? decoderEntites(inverse[1]) : null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Retire les commentaires JS d'un fragment de script ou d'une page.
|
|
364
|
+
*
|
|
365
|
+
* Indispensable : les pages de saison embarquent un gabarit **commenté** qui
|
|
366
|
+
* contient les mêmes appels (`resetListe(); creerListe(debut, fin); …`) que le
|
|
367
|
+
* code réel. Sans ce nettoyage, l'analyseur reconstruirait une liste fantôme.
|
|
368
|
+
*
|
|
369
|
+
* Volontairement conservateur sur les commentaires de ligne : `//` apparaît
|
|
370
|
+
* dans toutes les URLs (`https://…`, `//cdn.jsdelivr.net/…`), donc seules les
|
|
371
|
+
* lignes qui **commencent** par `//` sont retirées. C'est la forme qu'emploie
|
|
372
|
+
* le site (`//check si episode existe`, `//` final de `episodes.js`).
|
|
373
|
+
*/
|
|
374
|
+
export function retirerCommentairesJs(source: string): string {
|
|
375
|
+
return source
|
|
376
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
377
|
+
.replace(/^[ \t]*\/\/[^\n]*/gm, " ");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Vérifie qu'une chaîne est bien un code de langue connu du site. */
|
|
381
|
+
export function estLangue(valeur: string): valeur is LangueAnimesama {
|
|
382
|
+
return (LANGUES_ANIMESAMA as readonly string[]).includes(valeur);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Hébergeurs
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Nom canonique de l'hébergeur d'un lecteur.
|
|
391
|
+
*
|
|
392
|
+
* Le registre lui-même est dans le cœur média de bxc (`@aphrody/bxc/media`) :
|
|
393
|
+
* les mêmes hébergeurs se retrouvent derrière voiranime, et un domaine qui
|
|
394
|
+
* change doit être corrigé à un seul endroit. Seul le mot « inconnu » reste
|
|
395
|
+
* local — c'est le vocabulaire français de ce paquet.
|
|
396
|
+
*/
|
|
397
|
+
export function hebergeurDepuisUrl(url: string): string {
|
|
398
|
+
const nom = hostFromUrl(url);
|
|
399
|
+
return nom === "unknown" ? "inconnu" : nom;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Applique la réécriture que `videos.js` fait à l'exécution : les domaines
|
|
404
|
+
* `vidmoly.to` et `vidmoly.net` sont morts, seul `vidmoly.biz` répond.
|
|
405
|
+
*/
|
|
406
|
+
export function normaliserUrlLecteur(url: string): string {
|
|
407
|
+
return normalizeEmbedUrl(url);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Analyseurs purs — fiche d'une œuvre
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Extrait les saisons déclarées par les appels `panneauAnime` / `panneauScan`
|
|
416
|
+
* du `<script>` de la fiche.
|
|
417
|
+
*
|
|
418
|
+
* La définition JavaScript de ces deux fonctions est elle-même présente dans la
|
|
419
|
+
* page ; elle est ignorée car ses arguments sont des identifiants (`nom`,
|
|
420
|
+
* `url`) et non des chaînes littérales.
|
|
421
|
+
*/
|
|
422
|
+
export function parserSaisonsDeclarees(
|
|
423
|
+
html: string,
|
|
424
|
+
urlFiche: string,
|
|
425
|
+
): { saisons: SaisonRef[]; scans: SaisonRef[] } {
|
|
426
|
+
const base = urlFiche.replace(/\/+$/, "");
|
|
427
|
+
const saisons: SaisonRef[] = [];
|
|
428
|
+
const scans: SaisonRef[] = [];
|
|
429
|
+
const source = retirerCommentairesJs(html);
|
|
430
|
+
const re =
|
|
431
|
+
/panneau(Anime|Scan)\s*\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*"((?:[^"\\]|\\.)*)"\s*\)/g;
|
|
432
|
+
for (const m of source.matchAll(re)) {
|
|
433
|
+
const categorie = m[1] === "Scan" ? "scan" : "anime";
|
|
434
|
+
const nom = decoderEntites(m[2].replace(/\\(.)/g, "$1")).trim();
|
|
435
|
+
const chemin = m[3].replace(/\\(.)/g, "$1").replace(/^\/+|\/+$/g, "");
|
|
436
|
+
if (!chemin) continue;
|
|
437
|
+
const segments = chemin.split("/");
|
|
438
|
+
const dernier = segments[segments.length - 1] ?? "";
|
|
439
|
+
const langue = estLangue(dernier) ? dernier : null;
|
|
440
|
+
const ref: SaisonRef = {
|
|
441
|
+
nom,
|
|
442
|
+
chemin,
|
|
443
|
+
saison: langue ? segments.slice(0, -1).join("/") : chemin,
|
|
444
|
+
langue,
|
|
445
|
+
url: `${base}/${chemin}/`,
|
|
446
|
+
categorie,
|
|
447
|
+
};
|
|
448
|
+
(categorie === "scan" ? scans : saisons).push(ref);
|
|
449
|
+
}
|
|
450
|
+
return { saisons, scans };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Lit une ligne `.info-lbl` → `.info-val` de la carte d'informations. */
|
|
454
|
+
function ligneInfo(html: string, label: string): string | null {
|
|
455
|
+
const echappe = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
456
|
+
const re = new RegExp(
|
|
457
|
+
`<span class="info-lbl">[\\s\\S]*?${echappe}\\s*</span>\\s*<(span|div) class="info-val[^"]*"[^>]*>([\\s\\S]*?)</\\1>`,
|
|
458
|
+
"i",
|
|
459
|
+
);
|
|
460
|
+
const m = re.exec(html);
|
|
461
|
+
return m ? texteBrut(m[2]) : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Analyse une fiche `/catalogue/<slug>/` complète. */
|
|
465
|
+
export function parserFicheAnime(html: string, url: string): FicheAnime {
|
|
466
|
+
const slug = dernierSegment(url);
|
|
467
|
+
|
|
468
|
+
const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html);
|
|
469
|
+
const titre =
|
|
470
|
+
(h1 ? texteBrut(h1[1]) : "") ||
|
|
471
|
+
(metaContenu(html, "og:title") ?? "").split("|")[0].trim() ||
|
|
472
|
+
slug;
|
|
473
|
+
|
|
474
|
+
const alterM = /id="titreAlter"[^>]*>([\s\S]*?)<\/h2>/i.exec(html);
|
|
475
|
+
const titresAlternatifs = alterM
|
|
476
|
+
? texteBrut(alterM[1])
|
|
477
|
+
.split(",")
|
|
478
|
+
.map((s) => s.trim())
|
|
479
|
+
.filter(Boolean)
|
|
480
|
+
: [];
|
|
481
|
+
|
|
482
|
+
const synM = /id="synopsisText"[^>]*>([\s\S]*?)<\/p>/i.exec(html);
|
|
483
|
+
const synopsis = synM
|
|
484
|
+
? texteBrut(synM[1])
|
|
485
|
+
: metaContenu(html, "description");
|
|
486
|
+
|
|
487
|
+
const coverM = /id="coverOeuvre"[^>]+src="([^"]+)"/i.exec(html);
|
|
488
|
+
const image = coverM ? coverM[1] : metaContenu(html, "og:image");
|
|
489
|
+
|
|
490
|
+
const genres: string[] = [];
|
|
491
|
+
for (const m of html.matchAll(
|
|
492
|
+
/<span class="genre-pill">([\s\S]*?)<\/span>/g,
|
|
493
|
+
)) {
|
|
494
|
+
const g = texteBrut(m[1]);
|
|
495
|
+
if (g && !genres.includes(g)) genres.push(g);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const anneeRaw = ligneInfo(html, "Année");
|
|
499
|
+
const annee = anneeRaw ? parseInt(anneeRaw.replace(/\D/g, ""), 10) || null : null;
|
|
500
|
+
|
|
501
|
+
const episodesRaw = ligneInfo(html, "Épisodes");
|
|
502
|
+
const nombreEpisodesAnnonce = episodesRaw
|
|
503
|
+
? parseInt(episodesRaw.replace(/\D/g, ""), 10) || null
|
|
504
|
+
: null;
|
|
505
|
+
|
|
506
|
+
const studioRaw = ligneInfo(html, "Studio");
|
|
507
|
+
const studios = studioRaw
|
|
508
|
+
? studioRaw
|
|
509
|
+
.replace(/\s*Voir (?:plus|moins)\s*$/i, "")
|
|
510
|
+
.split(/,|·/)
|
|
511
|
+
.map((s) => s.trim())
|
|
512
|
+
.filter(Boolean)
|
|
513
|
+
: [];
|
|
514
|
+
|
|
515
|
+
const { saisons, scans } = parserSaisonsDeclarees(html, url);
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
slug,
|
|
519
|
+
url,
|
|
520
|
+
titre,
|
|
521
|
+
titresAlternatifs,
|
|
522
|
+
synopsis,
|
|
523
|
+
image,
|
|
524
|
+
genres,
|
|
525
|
+
etat: ligneInfo(html, "État"),
|
|
526
|
+
annee,
|
|
527
|
+
nombreEpisodesAnnonce,
|
|
528
|
+
studios,
|
|
529
|
+
saisons,
|
|
530
|
+
scans,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Analyseurs purs — recherche
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Analyse le fragment renvoyé par `POST /template-php/defaut/fetch.php`
|
|
540
|
+
* (une suite de `<a class="asn-search-result">`).
|
|
541
|
+
*/
|
|
542
|
+
export function parserResultatsRecherche(html: string): ResultatRecherche[] {
|
|
543
|
+
const out: ResultatRecherche[] = [];
|
|
544
|
+
const blocs = html.split(/<a\s+href="/i).slice(1);
|
|
545
|
+
for (const bloc of blocs) {
|
|
546
|
+
const hrefM = /^([^"]+)"/.exec(bloc);
|
|
547
|
+
if (!hrefM) continue;
|
|
548
|
+
const url = hrefM[1];
|
|
549
|
+
const slugM = /\/catalogue\/([^/"?#]+)/.exec(url);
|
|
550
|
+
if (!slugM) continue;
|
|
551
|
+
const titreM = /class="asn-search-result-title"[^>]*>([\s\S]*?)<\/h3>/i.exec(
|
|
552
|
+
bloc,
|
|
553
|
+
);
|
|
554
|
+
if (!titreM) continue;
|
|
555
|
+
const sousTitreM =
|
|
556
|
+
/class="asn-search-result-subtitle"[^>]*>([\s\S]*?)<\/p>/i.exec(bloc);
|
|
557
|
+
const imgM = /class="asn-search-result-img"[^>]*src="([^"]+)"/i.exec(bloc);
|
|
558
|
+
out.push({
|
|
559
|
+
slug: slugM[1],
|
|
560
|
+
titre: texteBrut(titreM[1]),
|
|
561
|
+
titresAlternatifs: sousTitreM
|
|
562
|
+
? texteBrut(sousTitreM[1])
|
|
563
|
+
.split(",")
|
|
564
|
+
.map((s) => s.trim())
|
|
565
|
+
.filter(Boolean)
|
|
566
|
+
: [],
|
|
567
|
+
url,
|
|
568
|
+
image: imgM ? imgM[1] : null,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Analyse les cartes `.catalog-card` d'une page `/catalogue/?search=…`. */
|
|
575
|
+
export function parserCartesCatalogue(html: string): ResultatRecherche[] {
|
|
576
|
+
const out: ResultatRecherche[] = [];
|
|
577
|
+
const seen = new Set<string>();
|
|
578
|
+
for (const bloc of html.split(/class="[^"]*catalog-card[^"]*"/i).slice(1)) {
|
|
579
|
+
const hrefM = /<a\s+href="([^"]*\/catalogue\/([^/"?#]+))"/i.exec(bloc);
|
|
580
|
+
if (!hrefM) continue;
|
|
581
|
+
if (seen.has(hrefM[2])) continue;
|
|
582
|
+
const titreM = /class="card-title"[^>]*>([\s\S]*?)<\/h2>/i.exec(bloc);
|
|
583
|
+
if (!titreM) continue;
|
|
584
|
+
const altM = /class="alternate-titles"[^>]*>([\s\S]*?)<\/p>/i.exec(bloc);
|
|
585
|
+
const imgM = /class="card-image"[^>]*src="([^"]+)"/i.exec(bloc);
|
|
586
|
+
seen.add(hrefM[2]);
|
|
587
|
+
out.push({
|
|
588
|
+
slug: hrefM[2],
|
|
589
|
+
titre: texteBrut(titreM[1]),
|
|
590
|
+
titresAlternatifs: altM
|
|
591
|
+
? texteBrut(altM[1])
|
|
592
|
+
.split(",")
|
|
593
|
+
.map((s) => s.trim())
|
|
594
|
+
.filter(Boolean)
|
|
595
|
+
: [],
|
|
596
|
+
url: hrefM[1],
|
|
597
|
+
image: imgM ? imgM[1] : null,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Analyseurs purs — episodes.js et liste d'épisodes
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Analyse un fichier `episodes.js` et renvoie un lecteur par variable `epsN`.
|
|
609
|
+
*
|
|
610
|
+
* Tolère les formes réelles rencontrées : déclarations sur une seule ligne,
|
|
611
|
+
* indentation quelconque, `eps2` déclaré avant `eps1`, `eps1` absent, virgule
|
|
612
|
+
* traînante avant `]`.
|
|
613
|
+
*/
|
|
614
|
+
export function parserLecteurs(js: string): Lecteur[] {
|
|
615
|
+
const source = retirerCommentairesJs(js);
|
|
616
|
+
const lecteurs: Lecteur[] = [];
|
|
617
|
+
const re = /\bvar\s+eps(\d+)\s*=\s*\[([\s\S]*?)\]/g;
|
|
618
|
+
for (const m of source.matchAll(re)) {
|
|
619
|
+
const index = parseInt(m[1], 10);
|
|
620
|
+
if (lecteurs.some((l) => l.index === index)) continue;
|
|
621
|
+
const urls: string[] = [];
|
|
622
|
+
for (const u of m[2].matchAll(/['"]([^'"]+)['"]/g)) {
|
|
623
|
+
const url = normaliserUrlLecteur(u[1].trim());
|
|
624
|
+
if (url) urls.push(url);
|
|
625
|
+
}
|
|
626
|
+
if (urls.length === 0) continue;
|
|
627
|
+
lecteurs.push({
|
|
628
|
+
index,
|
|
629
|
+
nom: "",
|
|
630
|
+
hebergeur: hebergeurDominant(urls),
|
|
631
|
+
urls,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
lecteurs.sort((a, b) => a.index - b.index);
|
|
635
|
+
// Le site nomme les lecteurs « Lecteur 1..n » dans l'ordre croissant des
|
|
636
|
+
// variables présentes, pas d'après le numéro de la variable elle-même.
|
|
637
|
+
lecteurs.forEach((l, i) => {
|
|
638
|
+
l.nom = `Lecteur ${i + 1}`;
|
|
639
|
+
});
|
|
640
|
+
return lecteurs;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Hébergeur majoritaire d'une liste d'URLs. */
|
|
644
|
+
function hebergeurDominant(urls: string[]): string {
|
|
645
|
+
const compte = new Map<string, number>();
|
|
646
|
+
for (const u of urls) {
|
|
647
|
+
const h = hebergeurDepuisUrl(u);
|
|
648
|
+
compte.set(h, (compte.get(h) ?? 0) + 1);
|
|
649
|
+
}
|
|
650
|
+
let meilleur = "inconnu";
|
|
651
|
+
let max = 0;
|
|
652
|
+
for (const [h, n] of compte) {
|
|
653
|
+
if (n > max) {
|
|
654
|
+
max = n;
|
|
655
|
+
meilleur = h;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return meilleur;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Rejoue les appels `resetListe` / `creerListe` / `newSP` / `newSPF` /
|
|
663
|
+
* `finirListe` de la page de saison pour reconstituer les libellés affichés.
|
|
664
|
+
*
|
|
665
|
+
* @param html Le HTML de la page de saison.
|
|
666
|
+
* @param taille Nombre d'épisodes réels (longueur d'un tableau `epsN`).
|
|
667
|
+
*
|
|
668
|
+
* Sémantique reprise de `/js/contenu/videos.js` :
|
|
669
|
+
* - `resetListe()` vide la liste (et remet le compteur d'épisodes spéciaux) ;
|
|
670
|
+
* - `creerListe(a, b)` ajoute « Episode a » … « Episode b » ;
|
|
671
|
+
* - `newSP(n)` ajoute « Episode n » et incrémente le compteur de spéciaux ;
|
|
672
|
+
* - `newSPF(nom)` ajoute le libellé libre `nom` et incrémente le compteur ;
|
|
673
|
+
* - `finirListe(a)` complète de `a` jusqu'à `taille - nombreDeSpeciaux`.
|
|
674
|
+
*
|
|
675
|
+
* Quand la page ne contient aucune directive exploitable, on retombe sur la
|
|
676
|
+
* numérotation par défaut « Episode 1 … Episode taille ».
|
|
677
|
+
*/
|
|
678
|
+
export function parserNomsEpisodes(html: string, taille: number): string[] {
|
|
679
|
+
const source = retirerCommentairesJs(html);
|
|
680
|
+
let noms: string[] = [];
|
|
681
|
+
let speciaux = 0;
|
|
682
|
+
let vuDirective = false;
|
|
683
|
+
|
|
684
|
+
const re =
|
|
685
|
+
/\b(resetListe|creerListe|newSPF|newSP|finirListe)\s*\(([^)]*)\)/g;
|
|
686
|
+
for (const m of source.matchAll(re)) {
|
|
687
|
+
const args = m[2].trim();
|
|
688
|
+
switch (m[1]) {
|
|
689
|
+
case "resetListe":
|
|
690
|
+
noms = [];
|
|
691
|
+
speciaux = 0;
|
|
692
|
+
vuDirective = true;
|
|
693
|
+
break;
|
|
694
|
+
case "creerListe": {
|
|
695
|
+
const bornes = args.match(/-?\d+/g);
|
|
696
|
+
if (!bornes || bornes.length < 2) break;
|
|
697
|
+
const debut = parseInt(bornes[0], 10);
|
|
698
|
+
const fin = parseInt(bornes[1], 10);
|
|
699
|
+
for (let i = debut; i <= fin; i++) noms.push(`Episode ${i}`);
|
|
700
|
+
vuDirective = true;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
case "newSP": {
|
|
704
|
+
const n = args.match(/-?\d+/);
|
|
705
|
+
if (!n) break;
|
|
706
|
+
noms.push(`Episode ${n[0]}`);
|
|
707
|
+
speciaux++;
|
|
708
|
+
vuDirective = true;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case "newSPF": {
|
|
712
|
+
const litteral = /^\s*(["'])([\s\S]*)\1\s*$/.exec(args);
|
|
713
|
+
if (!litteral) break;
|
|
714
|
+
noms.push(decoderEntites(litteral[2].replace(/\\(.)/g, "$1")));
|
|
715
|
+
speciaux++;
|
|
716
|
+
vuDirective = true;
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
case "finirListe": {
|
|
720
|
+
const n = args.match(/-?\d+/);
|
|
721
|
+
if (!n) break;
|
|
722
|
+
const debut = parseInt(n[0], 10);
|
|
723
|
+
for (let i = debut; i <= taille - speciaux; i++)
|
|
724
|
+
noms.push(`Episode ${i}`);
|
|
725
|
+
vuDirective = true;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!vuDirective || noms.length === 0) {
|
|
732
|
+
return Array.from({ length: taille }, (_, i) => `Episode ${i + 1}`);
|
|
733
|
+
}
|
|
734
|
+
// Le site tronque implicitement à la taille du tableau de lecteurs.
|
|
735
|
+
return noms.slice(0, taille);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Extrait le numéro d'un libellé (`"Episode 12"` → 12, `"Broly"` → null). */
|
|
739
|
+
export function numeroDepuisNom(nom: string): number | null {
|
|
740
|
+
const m = /^\s*(?:episode|épisode)\s+(\d+)/i.exec(nom);
|
|
741
|
+
return m ? parseInt(m[1], 10) : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Codes de langue pour lesquels la page imprime un drapeau.
|
|
746
|
+
*
|
|
747
|
+
* ⚠️ Ce n'est **pas** une liste de disponibilité : le gabarit imprime les dix
|
|
748
|
+
* drapeaux quelle que soit l'œuvre, tous en `hidden`, et c'est `videos.js` qui
|
|
749
|
+
* sonde `../<langue>` en HTTP pour révéler ceux qui existent. Seul
|
|
750
|
+
* {@link AnimesamaScraper.listerLangues} dit la vérité.
|
|
751
|
+
*/
|
|
752
|
+
export function parserDrapeauxLangues(html: string): LangueAnimesama[] {
|
|
753
|
+
const out: LangueAnimesama[] = [];
|
|
754
|
+
for (const m of html.matchAll(/id="switch([A-Z0-9]+)"/g)) {
|
|
755
|
+
const code = m[1].toLowerCase();
|
|
756
|
+
if (estLangue(code) && !out.includes(code)) out.push(code);
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Croise lecteurs et libellés pour produire la liste d'épisodes. */
|
|
762
|
+
export function composerEpisodes(
|
|
763
|
+
lecteurs: Lecteur[],
|
|
764
|
+
noms: string[],
|
|
765
|
+
): EpisodeAnimesama[] {
|
|
766
|
+
const total = Math.max(0, ...lecteurs.map((l) => l.urls.length));
|
|
767
|
+
const episodes: EpisodeAnimesama[] = [];
|
|
768
|
+
for (let i = 0; i < total; i++) {
|
|
769
|
+
const nom = noms[i] ?? `Episode ${i + 1}`;
|
|
770
|
+
episodes.push({
|
|
771
|
+
position: i,
|
|
772
|
+
nom,
|
|
773
|
+
numero: numeroDepuisNom(nom),
|
|
774
|
+
lecteurs: lecteurs
|
|
775
|
+
.filter((l) => l.urls[i])
|
|
776
|
+
.map((l) => ({
|
|
777
|
+
index: l.index,
|
|
778
|
+
nom: l.nom,
|
|
779
|
+
hebergeur: hebergeurDepuisUrl(l.urls[i]),
|
|
780
|
+
url: l.urls[i],
|
|
781
|
+
})),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return episodes;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Analyse la page de saison + son `episodes.js` en une {@link SaisonAnimesama}. */
|
|
788
|
+
export function parserSaison(
|
|
789
|
+
htmlPage: string,
|
|
790
|
+
jsEpisodes: string,
|
|
791
|
+
contexte: { slug: string; saison: string; langue: LangueAnimesama; url: string },
|
|
792
|
+
): SaisonAnimesama {
|
|
793
|
+
const lecteurs = parserLecteurs(jsEpisodes);
|
|
794
|
+
const taille = Math.max(0, ...lecteurs.map((l) => l.urls.length));
|
|
795
|
+
const noms = parserNomsEpisodes(htmlPage, taille);
|
|
796
|
+
|
|
797
|
+
const titreM = /id="titreOeuvre"[^>]*>([\s\S]*?)<\/h3>/i.exec(htmlPage);
|
|
798
|
+
const libelleM = /\$\("#avOeuvre"\)\.html\("([^"]*)"\)/.exec(htmlPage);
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
...contexte,
|
|
802
|
+
titre: titreM ? texteBrut(titreM[1]) : null,
|
|
803
|
+
libelle: libelleM ? decoderEntites(libelleM[1]) : null,
|
|
804
|
+
lecteurs,
|
|
805
|
+
episodes: composerEpisodes(lecteurs, noms),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// Résolution des lecteurs vers un flux direct
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
/** Déballe une charge `eval(function(p,a,c,k,e,d){…})` (Dean Edwards). */
|
|
814
|
+
export function deballerPacker(source: string): string {
|
|
815
|
+
return unpackPacker(source);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Classe une URL média d'après son extension. */
|
|
819
|
+
export function classerMedia(u: string): "hls" | "dash" | "mp4" | "unknown" {
|
|
820
|
+
return classifyMedia(u);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Cherche l'URL média dans le corps d'une page de lecteur.
|
|
825
|
+
*
|
|
826
|
+
* Couvre les lecteurs JW Player (`sources: [{ file: … }]`, y compris packés)
|
|
827
|
+
* utilisés par ansembed / embed4me / oneupload, et le cas **sibnet** dont la
|
|
828
|
+
* page `shell.php` ne contient qu'un chemin **relatif** `/v/<hash>/<id>.mp4`.
|
|
829
|
+
*
|
|
830
|
+
* @param base URL de l'embed, qui sert à résoudre les chemins relatifs.
|
|
831
|
+
*/
|
|
832
|
+
export function chercherMedia(
|
|
833
|
+
corps: string,
|
|
834
|
+
base: string,
|
|
835
|
+
): { url: string | null; poster: string | null } {
|
|
836
|
+
const [meilleur] = extractMediaCandidates(corps, base);
|
|
837
|
+
return {
|
|
838
|
+
url: meilleur?.url ?? null,
|
|
839
|
+
poster: extractPoster(corps, base),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Traduit une variante du cœur média dans le vocabulaire du paquet. */
|
|
844
|
+
function enQualite(variante: MediaVariant): QualiteMedia {
|
|
845
|
+
const label =
|
|
846
|
+
variante.label ??
|
|
847
|
+
variante.name ??
|
|
848
|
+
(variante.bandwidth ? `${Math.round(variante.bandwidth / 1000)}kbps` : "variante");
|
|
849
|
+
return {
|
|
850
|
+
label,
|
|
851
|
+
url: variante.url,
|
|
852
|
+
...(variante.width && variante.height
|
|
853
|
+
? { resolution: `${variante.width}x${variante.height}` }
|
|
854
|
+
: {}),
|
|
855
|
+
...(variante.bandwidth ? { bandePassante: variante.bandwidth } : {}),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// Transport
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
const UA_DEFAUT =
|
|
864
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Transport par défaut, adossé à bxc.
|
|
868
|
+
*
|
|
869
|
+
* Les GET passent par une page bxc (`profile` au choix, `static` = zéro spawn).
|
|
870
|
+
* Les POST — uniquement la recherche `fetch.php` — passent par `fetch` global,
|
|
871
|
+
* car `page.goto()` ne sait pas envoyer de corps.
|
|
872
|
+
*/
|
|
873
|
+
export function creerTransportBxc(opts: {
|
|
874
|
+
profile: ProfilAnimesama;
|
|
875
|
+
timeoutMs: number;
|
|
876
|
+
}): TransportHttp & { fermer: () => Promise<void> } {
|
|
877
|
+
let page: AnyPage | null = null;
|
|
878
|
+
|
|
879
|
+
const transport = async (requete: RequeteHttp): Promise<ReponseHttp> => {
|
|
880
|
+
if ((requete.methode ?? "GET") === "POST") {
|
|
881
|
+
const reponse = await fetch(requete.url, {
|
|
882
|
+
method: "POST",
|
|
883
|
+
headers: {
|
|
884
|
+
"User-Agent": UA_DEFAUT,
|
|
885
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
886
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
887
|
+
...(requete.referer ? { Referer: requete.referer } : {}),
|
|
888
|
+
...requete.enTetes,
|
|
889
|
+
},
|
|
890
|
+
body: requete.corps ?? "",
|
|
891
|
+
signal: AbortSignal.timeout(requete.timeoutMs ?? opts.timeoutMs),
|
|
892
|
+
});
|
|
893
|
+
return {
|
|
894
|
+
status: reponse.status,
|
|
895
|
+
corps: await reponse.text(),
|
|
896
|
+
url: reponse.url,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if (!page) page = await Browser.newPage({ profile: opts.profile });
|
|
900
|
+
const reponse = await page.goto(requete.url, {
|
|
901
|
+
timeoutMs: requete.timeoutMs ?? opts.timeoutMs,
|
|
902
|
+
referer: requete.referer,
|
|
903
|
+
});
|
|
904
|
+
return {
|
|
905
|
+
status: reponse.status,
|
|
906
|
+
corps: await page.content(),
|
|
907
|
+
url: reponse.url,
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
transport.fermer = async (): Promise<void> => {
|
|
912
|
+
if (!page) return;
|
|
913
|
+
try {
|
|
914
|
+
await page.close();
|
|
915
|
+
} catch {
|
|
916
|
+
/* rien à faire */
|
|
917
|
+
}
|
|
918
|
+
page = null;
|
|
919
|
+
};
|
|
920
|
+
return transport;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// Scraper
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
/** Client haut niveau pour anime-sama.to. */
|
|
928
|
+
export class AnimesamaScraper {
|
|
929
|
+
readonly baseUrl: string;
|
|
930
|
+
private readonly timeoutMs: number;
|
|
931
|
+
private readonly retries: number;
|
|
932
|
+
private readonly transport: TransportHttp;
|
|
933
|
+
private readonly fermerTransport: (() => Promise<void>) | null;
|
|
934
|
+
|
|
935
|
+
constructor(opts: AnimesamaOptions = {}) {
|
|
936
|
+
this.baseUrl = (opts.baseUrl ?? "https://anime-sama.to").replace(
|
|
937
|
+
/\/+$/,
|
|
938
|
+
"",
|
|
939
|
+
);
|
|
940
|
+
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
|
941
|
+
this.retries = opts.retries ?? 2;
|
|
942
|
+
if (opts.transport) {
|
|
943
|
+
this.transport = opts.transport;
|
|
944
|
+
this.fermerTransport = null;
|
|
945
|
+
} else {
|
|
946
|
+
const bxc = creerTransportBxc({
|
|
947
|
+
profile: opts.profile ?? "static",
|
|
948
|
+
timeoutMs: this.timeoutMs,
|
|
949
|
+
});
|
|
950
|
+
this.transport = bxc;
|
|
951
|
+
this.fermerTransport = bxc.fermer;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Résout un slug ou une URL absolue vers l'URL de la fiche. */
|
|
956
|
+
urlFiche(slugOuUrl: string): string {
|
|
957
|
+
if (/^https?:\/\//i.test(slugOuUrl))
|
|
958
|
+
return slugOuUrl.replace(/\/+$/, "") + "/";
|
|
959
|
+
return `${this.baseUrl}/catalogue/${slugOuUrl.replace(/^\/+|\/+$/g, "")}/`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** URL du dossier d'une saison. */
|
|
963
|
+
urlSaison(slug: string, saison: string, langue: LangueAnimesama): string {
|
|
964
|
+
return `${this.baseUrl}/catalogue/${slug}/${saison}/${langue}/`;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Exécute une requête avec reprises sur échec transitoire. */
|
|
968
|
+
private async requete(requete: RequeteHttp): Promise<ReponseHttp> {
|
|
969
|
+
let derniere: unknown;
|
|
970
|
+
for (let essai = 0; essai <= this.retries; essai++) {
|
|
971
|
+
try {
|
|
972
|
+
return await this.transport({
|
|
973
|
+
timeoutMs: this.timeoutMs,
|
|
974
|
+
referer: `${this.baseUrl}/`,
|
|
975
|
+
...requete,
|
|
976
|
+
});
|
|
977
|
+
} catch (err) {
|
|
978
|
+
derniere = err;
|
|
979
|
+
if (essai < this.retries) await Bun.sleep(400 * (essai + 1));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
throw new Error(`requête ${requete.url} échouée : ${String(derniere)}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Recherche instantanée, via `POST /template-php/defaut/fetch.php`.
|
|
987
|
+
* C'est l'endpoint qu'utilise la barre de recherche du site.
|
|
988
|
+
*/
|
|
989
|
+
async rechercher(requete: string): Promise<ResultatRecherche[]> {
|
|
990
|
+
const reponse = await this.requete({
|
|
991
|
+
url: `${this.baseUrl}/template-php/defaut/fetch.php`,
|
|
992
|
+
methode: "POST",
|
|
993
|
+
corps: `query=${encodeURIComponent(requete)}`,
|
|
994
|
+
});
|
|
995
|
+
if (reponse.status !== 200)
|
|
996
|
+
throw new Error(`rechercher(${requete}) : HTTP ${reponse.status}`);
|
|
997
|
+
return parserResultatsRecherche(reponse.corps);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Parcourt le catalogue paginé (`/catalogue/?search=…&page=N`).
|
|
1002
|
+
* Plus lent que {@link rechercher} mais renvoie les genres et permet la
|
|
1003
|
+
* pagination sur de gros ensembles.
|
|
1004
|
+
*/
|
|
1005
|
+
async parcourirCatalogue(
|
|
1006
|
+
opts: { recherche?: string; pages?: number } = {},
|
|
1007
|
+
): Promise<ResultatRecherche[]> {
|
|
1008
|
+
const pages = opts.pages ?? 1;
|
|
1009
|
+
const vus = new Map<string, ResultatRecherche>();
|
|
1010
|
+
for (let p = 1; p <= pages; p++) {
|
|
1011
|
+
const params = new URLSearchParams();
|
|
1012
|
+
if (opts.recherche) params.set("search", opts.recherche);
|
|
1013
|
+
if (p > 1) params.set("page", String(p));
|
|
1014
|
+
const qs = params.toString();
|
|
1015
|
+
const reponse = await this.requete({
|
|
1016
|
+
url: `${this.baseUrl}/catalogue/${qs ? `?${qs}` : ""}`,
|
|
1017
|
+
});
|
|
1018
|
+
if (reponse.status !== 200) break;
|
|
1019
|
+
const lot = parserCartesCatalogue(reponse.corps);
|
|
1020
|
+
let ajoutes = 0;
|
|
1021
|
+
for (const r of lot)
|
|
1022
|
+
if (!vus.has(r.slug)) {
|
|
1023
|
+
vus.set(r.slug, r);
|
|
1024
|
+
ajoutes++;
|
|
1025
|
+
}
|
|
1026
|
+
if (ajoutes === 0) break;
|
|
1027
|
+
}
|
|
1028
|
+
return [...vus.values()];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Récupère et analyse la fiche d'une œuvre. */
|
|
1032
|
+
async getAnime(slugOuUrl: string): Promise<FicheAnime> {
|
|
1033
|
+
const url = this.urlFiche(slugOuUrl);
|
|
1034
|
+
const reponse = await this.requete({ url });
|
|
1035
|
+
if (reponse.status !== 200)
|
|
1036
|
+
throw new Error(`getAnime(${url}) : HTTP ${reponse.status}`);
|
|
1037
|
+
return parserFicheAnime(reponse.corps, url);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Récupère le `episodes.js` brut d'une saison (ou `null` si 404). */
|
|
1041
|
+
async getEpisodesJs(
|
|
1042
|
+
slug: string,
|
|
1043
|
+
saison: string,
|
|
1044
|
+
langue: LangueAnimesama,
|
|
1045
|
+
): Promise<string | null> {
|
|
1046
|
+
const url = `${this.urlSaison(slug, saison, langue)}episodes.js`;
|
|
1047
|
+
const reponse = await this.requete({
|
|
1048
|
+
url,
|
|
1049
|
+
referer: this.urlSaison(slug, saison, langue),
|
|
1050
|
+
});
|
|
1051
|
+
if (reponse.status !== 200) return null;
|
|
1052
|
+
return reponse.corps;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Récupère et analyse une saison complète (page + `episodes.js`). */
|
|
1056
|
+
async getSaison(
|
|
1057
|
+
slug: string,
|
|
1058
|
+
saison: string,
|
|
1059
|
+
langue: LangueAnimesama,
|
|
1060
|
+
): Promise<SaisonAnimesama> {
|
|
1061
|
+
const url = this.urlSaison(slug, saison, langue);
|
|
1062
|
+
const page = await this.requete({ url });
|
|
1063
|
+
if (page.status !== 200)
|
|
1064
|
+
throw new Error(`getSaison(${url}) : HTTP ${page.status}`);
|
|
1065
|
+
const js = await this.getEpisodesJs(slug, saison, langue);
|
|
1066
|
+
if (js === null)
|
|
1067
|
+
throw new Error(`getSaison(${url}) : episodes.js introuvable`);
|
|
1068
|
+
return parserSaison(page.corps, js, { slug, saison, langue, url });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Sonde les langues réellement publiées pour une saison.
|
|
1073
|
+
*
|
|
1074
|
+
* Le sélecteur de la page imprime tous les drapeaux ; seule la présence
|
|
1075
|
+
* d'un `episodes.js` non vide fait foi, d'où le sondage.
|
|
1076
|
+
*/
|
|
1077
|
+
async listerLangues(
|
|
1078
|
+
slug: string,
|
|
1079
|
+
saison: string,
|
|
1080
|
+
langues: readonly LangueAnimesama[] = LANGUES_ANIMESAMA,
|
|
1081
|
+
): Promise<LangueAnimesama[]> {
|
|
1082
|
+
const out: LangueAnimesama[] = [];
|
|
1083
|
+
for (const langue of langues) {
|
|
1084
|
+
const js = await this.getEpisodesJs(slug, saison, langue);
|
|
1085
|
+
if (js && parserLecteurs(js).length > 0) out.push(langue);
|
|
1086
|
+
}
|
|
1087
|
+
return out;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Récupère la fiche puis toutes ses saisons animées.
|
|
1092
|
+
* Les saisons illisibles (404, `episodes.js` vide) sont ignorées.
|
|
1093
|
+
*/
|
|
1094
|
+
async getAnimeComplet(
|
|
1095
|
+
slugOuUrl: string,
|
|
1096
|
+
): Promise<FicheAnime & { saisonsResolues: SaisonAnimesama[] }> {
|
|
1097
|
+
const fiche = await this.getAnime(slugOuUrl);
|
|
1098
|
+
const saisonsResolues: SaisonAnimesama[] = [];
|
|
1099
|
+
for (const ref of fiche.saisons) {
|
|
1100
|
+
if (!ref.langue) continue;
|
|
1101
|
+
try {
|
|
1102
|
+
saisonsResolues.push(
|
|
1103
|
+
await this.getSaison(fiche.slug, ref.saison, ref.langue),
|
|
1104
|
+
);
|
|
1105
|
+
} catch {
|
|
1106
|
+
/* saison indisponible : on continue */
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return { ...fiche, saisonsResolues };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Résout une URL d'embed vers un flux direct.
|
|
1114
|
+
*
|
|
1115
|
+
* Fiable pour les lecteurs JW Player (ansembed, embed4me, oneupload) et pour
|
|
1116
|
+
* sibnet. YouTube et Dailymotion sont signalés comme non résolubles : ce
|
|
1117
|
+
* sont des lecteurs propriétaires, pas des embeds de fichier.
|
|
1118
|
+
*/
|
|
1119
|
+
async resoudreLecteur(
|
|
1120
|
+
lecteur: LecteurEpisode | string,
|
|
1121
|
+
opts: { enumererQualites?: boolean } = {},
|
|
1122
|
+
): Promise<SourceResolue> {
|
|
1123
|
+
const embedUrl = normaliserUrlLecteur(
|
|
1124
|
+
typeof lecteur === "string" ? lecteur : lecteur.url,
|
|
1125
|
+
);
|
|
1126
|
+
const hebergeur =
|
|
1127
|
+
typeof lecteur === "string" ? hebergeurDepuisUrl(embedUrl) : lecteur.hebergeur;
|
|
1128
|
+
|
|
1129
|
+
// Certains épisodes pointent directement sur un fichier hébergé par le
|
|
1130
|
+
// site : rien à résoudre, l'URL *est* le flux.
|
|
1131
|
+
const direct = classerMedia(embedUrl);
|
|
1132
|
+
if (direct !== "unknown") {
|
|
1133
|
+
return {
|
|
1134
|
+
hebergeur,
|
|
1135
|
+
embedUrl,
|
|
1136
|
+
type: direct,
|
|
1137
|
+
url: embedUrl,
|
|
1138
|
+
poster: null,
|
|
1139
|
+
enTetes: { Referer: `${this.baseUrl}/` },
|
|
1140
|
+
erreur: null,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const media = await resolveEmbed(embedUrl, {
|
|
1145
|
+
transport: this.transportMedia(),
|
|
1146
|
+
referer: `${this.baseUrl}/`,
|
|
1147
|
+
enumerateVariants: opts.enumererQualites,
|
|
1148
|
+
timeoutMs: this.timeoutMs,
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
return {
|
|
1152
|
+
hebergeur: media.host === "unknown" ? hebergeur : media.host,
|
|
1153
|
+
embedUrl: media.embedUrl,
|
|
1154
|
+
type: media.kind,
|
|
1155
|
+
url: media.url,
|
|
1156
|
+
poster: media.poster,
|
|
1157
|
+
enTetes: media.headers,
|
|
1158
|
+
erreur: media.error ?? null,
|
|
1159
|
+
...(media.variants.length ? { qualites: media.variants.map(enQualite) } : {}),
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Adapte le transport du scraper à celui qu'attend le cœur média.
|
|
1165
|
+
*
|
|
1166
|
+
* Les deux disent la même chose dans deux langues : cette fonction est la
|
|
1167
|
+
* frontière entre le vocabulaire français du paquet et celui du cœur.
|
|
1168
|
+
*/
|
|
1169
|
+
private transportMedia(): MediaTransport {
|
|
1170
|
+
return async (requete) => {
|
|
1171
|
+
const reponse = await this.requete({
|
|
1172
|
+
url: requete.url,
|
|
1173
|
+
methode: requete.method,
|
|
1174
|
+
enTetes: requete.headers,
|
|
1175
|
+
referer: requete.referer,
|
|
1176
|
+
corps: requete.body,
|
|
1177
|
+
timeoutMs: requete.timeoutMs,
|
|
1178
|
+
});
|
|
1179
|
+
return { status: reponse.status, body: reponse.corps, url: reponse.url };
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Récupère et analyse un master HLS pour en lister les variantes. */
|
|
1184
|
+
async enumererQualitesHls(
|
|
1185
|
+
urlMaster: string,
|
|
1186
|
+
referer: string,
|
|
1187
|
+
): Promise<QualiteMedia[]> {
|
|
1188
|
+
const variantes = await resolveVariants(urlMaster, {
|
|
1189
|
+
transport: this.transportMedia(),
|
|
1190
|
+
referer,
|
|
1191
|
+
timeoutMs: this.timeoutMs,
|
|
1192
|
+
});
|
|
1193
|
+
return variantes.map(enQualite);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Libère la page bxc sous-jacente (sans effet si le transport est injecté). */
|
|
1197
|
+
async close(): Promise<void> {
|
|
1198
|
+
if (this.fermerTransport) await this.fermerTransport();
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export default AnimesamaScraper;
|