UNPKG

cronchy

Version:

Crunchyroll scraper for https://crunchyroll.com.

798 lines (710 loc) 27.7 kB
import PromiseRequest from "./libraries/promise-request"; class Cronchy { // Constant, might need to be changed. public token:string = "a3ZvcGlzdXZ6Yy0teG96Y21kMXk6R21JSTExenVPVnRnTjdlSWZrSlpibzVuLTRHTlZ0cU8="; public email:string; public password:string; public accessToken:string; public refreshToken:string; private accountId:string; private bucket:string; private signature:string private policy:string; private key_pair_id: string; private main:string; private api:string; /** * @param email The email of the account. * @param password Password of the account. * @param token Optional token. If it has changed, you can pass it in. */ constructor(email:string, password:string, token?:string) { this.email = email; this.password = password; this.token = token ? token : "a3ZvcGlzdXZ6Yy0teG96Y21kMXk6R21JSTExenVPVnRnTjdlSWZrSlpibzVuLTRHTlZ0cU8="; this.main = "https://www.crunchyroll.com"; this.api = "https://beta-api.crunchyroll.com"; } /** * @important Must be run before any other function. */ public async login(): Promise<AccountData> { const params = new URLSearchParams(); params.append("username", this.email); params.append("password", this.password); params.append("grant_type", "password"); params.append("scope", "offline_access"); const req = new PromiseRequest(`${this.api}/auth/v1/token`, { data: params, headers: { Authorization: `Basic ${this.token}`, "Content-Type": "application/x-www-form-urlencoded" }, method: "POST" }); const res = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/auth/v1/token failed.\nEmail: ${this.email}; Password: ${this.password}; Token: ${this.token}`); }); if (res.status != 200) { throw new Error(res.toString()); } const response = res.json(); const data = { access_token: response["access_token"], refresh_token: response["refresh_token"], expires_in: response["expires_in"], token_type: response["token_type"], scope: response["scope"], country: response["country"], account_id: response["account_id"] }; this.accessToken = data.access_token this.refreshToken = data.refresh_token; this.accountId = data.account_id; const sigReq = new PromiseRequest(`${this.api}/index/v2`, { headers: { Authorization: "Bearer " + data.access_token, }, }); const sig = await sigReq.request().catch((err) => { throw new Error(`Request to ${this.api}/index/v2 failed.\nBearer: ${data.access_token}`); }); const signature = sig.json(); if (!signature || !signature.cms) { throw new Error(`Request to ${this.api}/index/v2 failed.\nBearer: ${data.access_token}`); } const sig_data = { signature: signature.cms.signature, key_pair_id: signature.cms.key_pair_id, bucket: signature.cms.bucket, policy: signature.cms.policy, } this.signature = sig_data.signature; this.key_pair_id = sig_data.key_pair_id; this.bucket = sig_data.bucket; this.policy = sig_data.policy; return { access_token: data.access_token, expires_in: data.expires_in, token_type: data.token_type, scope: data.scope, country: data.country, account_id: data.account_id, signature: sig_data.signature, key_pair_id: sig_data.key_pair_id, bucket: sig_data.bucket, policy: sig_data.policy, } } /** * @param query Search query. Takes a string. * @param amount Max amount of search results. Takes a number. */ public async search(query:string, amount?:number): Promise<SearchData> { amount = amount ? amount : 8; const req = new PromiseRequest(`${this.api}/content/v2/discover/search?q=${encodeURIComponent(query)}&n=${amount}&type=&locale=en-US`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const search = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content/v2/discover/search?q=${encodeURIComponent(query)}&n=${amount}&type=&locale=en-US failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); if (search.status != 200) { throw new Error(search.toString()); } return search.json(); } public async queryShowData(id:string, locale:string, mediaType:MediaType): Promise<ShowData> { const req = new PromiseRequest(`${this.api}/content/v2/cms/${mediaType}/${id}?locale=${locale}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const cr_data = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content/v2/cms/${mediaType}/${id}?locale=${locale} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return cr_data.json(); } public async queryGenreData(id:string, locale:string): Promise<GenreQuery> { const req = new PromiseRequest(`${this.api}/content/v1/tenant_categories?guid=${id}?locale=${locale}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const cr_genre_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content/v1/tenant_categories?guid=${id}?locale=${locale} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return cr_genre_response.json(); } public async queryRatings(id:string): Promise<RatingsQuery> { const req = new PromiseRequest(`${this.api}/content-reviews/v2/user/${this.accountId}/rating/series/${id}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const cr_ratings_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content-reviews/v2/user/${this.accountId}/rating/series/${id} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return cr_ratings_response.json(); } public async queryRecommendations(id:string, locale:string): Promise<RecommendationsQuery> { const req = new PromiseRequest(`${this.api}/content/v1/${this.accountId}/similar_to?guid=${id}&locale=${locale}&n=30`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const cr_recommendations_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content/v1/${this.accountId}/similar_to?guid=${id}&locale=${locale}&n=30 failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return cr_recommendations_response.json(); } public async querySeason(id:string, locale:string): Promise<SeasonQuery> { const req = new PromiseRequest(`${this.api}/cms/v2${this.bucket}/seasons?series_id=${id}&locale=${locale}&Signature=${this.signature}&Policy=${this.policy}&Key-Pair-Id=${this.key_pair_id}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const seasons_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/cms/v2${this.bucket}/seasons?series_id=${id}&locale=${locale}&Signature=${this.signature}&Policy=${this.policy}&Key-Pair-Id=${this.key_pair_id} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return seasons_response.json(); } public async queryEpisodes(season:SeasonInfo, locale:string): Promise<EpisodeQuery> { const req = new PromiseRequest(`${this.api}/cms/v2${this.bucket}/episodes?season_id=${season?.id}&locale=${locale}&Signature=${this.signature}&Policy=${this.policy}&Key-Pair-Id=${this.key_pair_id}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const episode_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/cms/v2${this.bucket}/episodes?season_id=${season?.id}&locale=${locale}&Signature=${this.signature}&Policy=${this.policy}&Key-Pair-Id=${this.key_pair_id} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); return episode_response.json(); } /** * @param seriesQuery SearchQuery object. Can be obtained from the search function. * @returns */ public getLocaleFromSearchQuery(seriesQuery:SearchQuery): string { const locale = seriesQuery.series_metadata["subtitle_locales"].length > 0 ? seriesQuery.series_metadata["subtitle_locales"][0] : seriesQuery.series_metadata["audio_locales"][0]; return locale; } /** * @param seriesQuery SearchQuery object. Can be obtained from the search function. * @returns */ public getMediaTypeFromSearchQuery(seriesQuery:SearchQuery):MediaType["episode"]|MediaType["movie_listing"]|MediaType["objects"]|MediaType["series"]|MediaType["top_results"] { const type = seriesQuery.type; return type; } /** * @param id SearchQuery id. Can be obtained from the search function. * @param locale The locale of the show. Can be obtained from the search function or via getLocaleFromSearchQuery(). * @param mediaType The type of media. Must be a valid Crunchyroll series string. * @param fetchAll Whether or not to fetch all "seasons" (whatever Crunchyroll means by that lol). If false, only the the episodes from the "season" will be fetched. */ public async getEpisodes(id:SearchQuery["id"], locale:string, mediaType:MediaType, fetchAll: boolean): Promise<Show> { const cr_data = await this.queryShowData(id, locale, mediaType); const cr_genre_response = await this.queryGenreData(id, locale); const cr_ratings_data = await this.queryRatings(id); const cr_recommendations_data = await (await this.queryRecommendations(id, locale)).items; const season_response = await this.querySeason(id, locale); const cr_genre_data = cr_genre_response.items; const genres: string[] = []; cr_genre_data?.forEach((genre: GenreInfo) => { genres.push(genre.localization.title); }); const season_data = season_response.items; let season_list: any[] = []; season_data?.forEach((season: SeasonInfo) => { season_list.push({ id: season.id, title: season.title, season_number: season.season_number, type: season.is_dubbed ? season.title.split("(")[1].replace(")", "") : "subbed", isDub: season.is_dubbed, }); }); const episodes: {}[] = []; if (fetchAll) { const promises = []; season_list.map(((season: SeasonInfo) => { const promise = new Promise(async(resolve, reject) => { const episode_response = await this.queryEpisodes(season, locale); const episode_data = await episode_response.items; const season_episode_list = { [season.type]: [ episode_data.map((episode: EpisodeInfo) => { return { id: episode.id, season_number: season.season_number, title: episode.title, image: episode.images.thumbnail[0][ episode.images.thumbnail[0].length - 1 ].source, description: episode.description, releaseDate: episode.episode_air_date, isHD: episode.hd_flag, isAdult: episode.is_mature, isDubbed: episode.is_dubbed, isSubbed: episode.is_subbed, duration: episode.duration, }; }), ], }; episodes.push(season_episode_list); resolve(season_episode_list); }) promises.push(promise); })) await Promise.all(promises); } else { const season = season_list[0]; const episode_response = await this.queryEpisodes(season, locale); const episode_data = await episode_response.items; episode_data?.map((episode: EpisodeInfo) => { return { id: episode.id, season_number: season.season_number, title: episode.title, image: episode.images.thumbnail[0][episode.images.thumbnail[0].length - 1].source, description: episode.description, releaseDate: episode.episode_air_date, isHD: episode.hd_flag, isAdult: episode.is_mature, isDubbed: episode.is_dubbed, isSubbed: episode.is_subbed, duration: episode.duration, }; }) if (episode_data != undefined) { episodes?.push(...episode_data); } } let returnData:Show; if (cr_data?.data) { returnData = { id: id, title: cr_data?.data[0]?.title, isAdult: cr_data?.data[0]?.title, image: cr_data?.data[0]?.images.poster_tall[0][cr_data?.data[0].images.poster_tall[0].length - 1].source, cover: cr_data?.data[0]?.images.poster_wide[0][cr_data?.data[0].images.poster_wide[0].length - 1].source, description: cr_data?.data[0]?.description, releaseDate: cr_data?.data[0]?.series_launch_year, genres: genres, season: cr_data?.data[0]?.season_tags[0].split("-")[0], hasDub: cr_data?.data[0]?.is_dubbed, hasSub: cr_data?.data[0]?.is_subbed, rating: cr_ratings_data?.average, recommendations: cr_recommendations_data?.map((rec:RecommendationsInfo) => { return { id: id, title: rec.title, isAdult: rec.is_mature, image: rec.images.poster_tall[0][rec.images.poster_tall[0].length - 1].source, popularity: 0, cover: rec.images.poster_wide[0][rec.images.poster_wide[0].length - 1].source, description: rec.description, releaseDate: rec.series_launch_year, hasDub: rec.is_dubbed, hasSub: rec.is_subbed, }; }), episodes: episodes, }; } else { returnData = { id: id, title: "undefined", isAdult: "undefined", image: "undefined", cover: "undefined", description: "undefined", releaseDate: -1, genres: [], season: "undefined", hasDub: false, hasSub: false, rating: cr_ratings_data?.average, recommendations: cr_recommendations_data?.map((rec:RecommendationsInfo) => { return { id: id, title: rec.title, isAdult: rec.is_mature, image: rec.images.poster_tall[0][rec.images.poster_tall[0].length - 1].source, popularity: 0, cover: rec.images.poster_wide[0][rec.images.poster_wide[0].length - 1].source, description: rec.description, releaseDate: rec.series_launch_year, hasDub: rec.is_dubbed, hasSub: rec.is_subbed, }; }), episodes: episodes, }; } return returnData; } /** * @param episodeId The episode ID of the show. * @param locale The locale of the episode. For example, "en-US". */ public async getSources(episodeId:string, locale:string): Promise<Sources> { const req = new PromiseRequest(`${this.api}/content/v2/cms/objects/${episodeId}?locale=${locale}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const temp_response = await req.request().catch((err) => { throw new Error(`Request to ${this.api}/content/v2/cms/objects/${episodeId}?locale=${locale} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); const temp_data = temp_response.json().data[0]; const episode_req = new PromiseRequest(`${this.api}${temp_data.streams_link}?locale=${locale}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Referer: this.main }, }); const episode_response = await episode_req.request().catch((err) => { throw new Error(`Request to ${this.api}${temp_data.streams_link}?locale=${locale} failed.\nBearer: ${this.accessToken}; Referer: ${this.main}`); }); const episode_data = episode_response.json(); const sources = []; const m3u8_req = new PromiseRequest(episode_data.data[0].vo_adaptive_hls[""].url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36", Referer: this.main, }, }); const m3u8Urls = await m3u8_req.request().catch((err) => { throw new Error(`Request to ${episode_data.data[0].vo_adaptive_hls[""].url}; User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36"; Referer: ${this.main}`); }); const videoList = m3u8Urls.text().split("#EXT-X-STREAM-INF:"); for (const video of videoList ?? []) { if (!video.includes("m3u8")) continue; const url = video.split("\n")[1]; const quality = video.split("RESOLUTION=")[1].split(",")[0].split("x")[1]; sources.push({ url: url, quality: `${quality}p`, isM3U8: true, }); } sources.push({ quality: "auto", url: episode_data.data[0].vo_adaptive_hls[""].url, isM3U8: true, }); let subtitles: any[] = []; for (var key of Object.keys(episode_data.meta.subtitles)) { subtitles.push({ url: episode_data.meta.subtitles[key].url, lang: key, format: episode_data.meta.subtitles[key].format, }); } return { sources: sources, subtitles: subtitles, }; } } interface AccountData { access_token: string; refresh_token?: string; expires_in: number; token_type: string; scope: string; country: string; account_id: string; signature: string; key_pair_id: string; bucket: string; policy: string; } interface SearchData { total: number; data: Array<SearchResult>; meta: JSON; } interface SearchResult { type: MediaType["top_results"] | MediaType["series"] | MediaType["movie_listing"] | MediaType["episode"]; count: number; items: Array<SearchQuery>; } interface SearchQuery { linked_resource_key?: string; search_metadata?: JSON; title?: string; new?: boolean; external_id?: string; type: MediaType["movie_listing"] | MediaType["objects"] | MediaType["series"] | MediaType["episode"]; slug_title?: string; images?: JSON; series_metadata?: JSON; id: string; description?: string | Array<String>; promo_title?: string; channel_id?: string; promo_description?: string; slug?: string; } interface ShowData { total: number; data: Array<ShowInfo>; meta: JSON; } interface Show { id: string; title: string; isAdult: string|boolean; image: string; cover: string; description: string; releaseDate: number; genres: string[]; season: string; hasDub: boolean; hasSub: boolean; rating: string; recommendations: any; episodes: any; } interface ShowInfo { is_subbed: boolean; is_dubbed: boolean; is_simulcast: boolean; is_mature: boolean; mature_blocked: boolean; id: string; title: string; slug_title: string; description: string; content_provider: string; seo_title: string; availability_notes: string; channel_id: string; episode_count: number; season_count: number; series_launch_year: number; media_count: number; extended_maturity_rating: JSON; images: any; maturity_ratings: Array<string>; audio_locales: Array<string>; season_tags: Array<string>; keywords: Array<string>; subtitle_locales: Array<string>; } interface GenreQuery { total: number; items: Array<GenreInfo>; meta: JSON; } interface GenreInfo { tenant_category: string; images: any; localization: Localization; slug: string; __href__: string; } interface Localization { title: string; description: string; locale: string; } interface RatingsQuery { "1s": Rating; "2s": Rating; "3s": Rating; "4s": Rating; "5s": Rating; average: string; total: number; rating: string; } interface Rating { displayed: number; unit: any; percentage: number; } interface RecommendationsQuery { total: number; items: Array<RecommendationsInfo>; __class__: string; __href__: string; __resource_key__: string; __links__: JSON; } interface RecommendationsInfo { id: string; is_subbed: boolean; is_dubbed: boolean; is_mature: boolean; series_launch_year: number; type: MediaType["movie_listing"] | MediaType["objects"] | MediaType["series"] | MediaType["episode"]; title: string; slug_title: string; promo_title: string; description: string; promo_description: string; new: boolean; new_content: boolean; slug: string; channel_id: string; external_id: string; linked_resource_key: string; series_metadata: JSON; images: any; search_metadata: JSON; __href__: string; __class__: string; __links__: JSON; __actions__: JSON; } interface SeasonQuery { total: number; items: Array<SeasonInfo>; __class__: string; __href__: string; __resource_key__: string; __links: JSON; __actions__: JSON; } interface SeasonInfo { id: string; type: MediaType["movie_listing"] | MediaType["objects"] | MediaType["series"] | MediaType["episode"]; series_id: string; title: string; seo_title: string; seo_description: string; availability_notes: string; slug_title: string; description: string; channel_id: string; season_display_number: string; season_sequence_number: number; season_number: number; is_complete: boolean; is_mature: boolean; mature_blocked: boolean; is_subbed: boolean; is_dubbed: boolean; is_simulcast: boolean; keywords: Array<string>; season_tags: Array<string>; maturity_ratings: Array<string>; audio_locales: Array<string>; subtitle_locales: Array<string>; audio_locale: string; versions: any; identifier: string; images: any; extended_maturity_rating: JSON; __class__: string; __href__: string; __resource_key__: string; __links__: JSON; __actions__: JSON; } interface EpisodeQuery { total: number; items: Array<EpisodeInfo>; __class__: string; __href__: string; __resource_key__: string; __links__: JSON; __actions__: JSON; } interface EpisodeInfo { id: string; title: string; slug_title: string; description: string; next_episode_id: string; next_episode_title: string; hd_flag: boolean; maturity_ratings: Array<string>; is_mature: boolean; mature_blocked: boolean; episode_air_date: string; upload_date: string; availability_starts: string; availability_ends: string; eligible_region: any; available_date: any; free_available_date: any; premium_date: any; premium_available_date: any; is_subbed: boolean; is_dubbed: boolean; is_clip: boolean; seo_title: string; seo_description: string; season_tags: Array<string>; playback: string; availability_notes: string; audio_locale: string; versions: any; closed_captions_available: boolean; identifier: string; media_type: MediaType["movie_listing"] | MediaType["objects"] | MediaType["series"] | MediaType["episode"]; slug: string; images: any; duration_ms: number; is_premium_only: string; listing_id: string; channel_id: string; series_id: string; series_title: string; series_slug_title: string; season_id: string; season_title: string; season_slug_title: string; season_number: number; episode: string; episode_number: number; sequence_number: number; production_episode_id: string; duration: number; __class__: string; __href__: string; __resource_key__: string; __links__: JSON; __actions__: JSON; } interface MediaType { "series"?: string; "objects"?: string; "movie_listing"?: string; "episode"?: string; "top_results"?: string; } interface Sources { sources: Array<Source>; subtitles: Array<Subtitle>; } interface Source { url: string; quality: string; isM3U8: boolean; } interface Subtitle { url: string; lang: string; format: string; } export default Cronchy; export type { AccountData, SearchData, SearchQuery, ShowData, ShowInfo, GenreQuery, GenreInfo, Localization, RatingsQuery, Rating, RecommendationsQuery, RecommendationsInfo, SeasonQuery, SeasonInfo, EpisodeQuery, EpisodeInfo, MediaType, Sources, Subtitle, Show };