import bytes from "bytes"; import fetch from "cross-fetch"; import { load, CheerioAPI, Element } from 'cheerio'; export type EpisodeType = { showLink: string | undefined; title: string; magnet: string | undefined; torrent: string | undefined; size: number; released: string; seeds: number; } export type ShowType = { title: string; summary: string; description: string; imdbId: string | null; episodes: EpisodeType[]; } export type ImdbEpisodeType = { id: number; hash: string; filename: string; episode_url: string; torrent_url: string; magnet_url: string; title: string; imdb_id: string; season: string; episode: string; small_screenshot: string; large_screenshot: string; seeds: number; peers: number; date_released_unix: number; size_bytes: string; } export type ApiResponseType = { imdb_id?: string; torrents_count: number; limit: number; page: number; torrents: ImdbEpisodeType[]; } /** * Crawl any given url * * @link https://www.npmjs.com/package/crawler * @param url * @returns `Crawler.CrawlerRequestResponse` */ async function crawl(url: string) { const body = await fetch(url).then(async resp => resp.text()); const $ = load(body); return { body, $ }; } /** * Get all shows listen on EZTV * * @returns `object` */ export async function getShows() { const { $ } = await crawl(`https://eztv.wf/showlist/`); const shows = $('a[class="thread_link"]').toArray(); return shows.map(show => { const showIdRegex = $(show).attr('href')?.match(/shows\/(\d+)\//); return { id: showIdRegex ? parseInt(showIdRegex[1]) : null, title: $(show).text() } }).filter(show => show.id) as { id: number, title: string }[]; } /** * Get a show and its episodes * * Recommended to use an ID, if using a showname * it must be an exact match except for uppercase * * @param showId - A show ID or a show name * @returns `object` */ export async function getShow(show: number|string): Promise { /** * If a string is passed find show based on title */ if(typeof show === 'string') { const shows = await getShows(); const findShow = shows.find(s => s.title.toLowerCase() === show.toLowerCase()); if (!findShow) { throw new EztvCrawlerException(`Did not find a show with name ${show}`) } return getShow(findShow.id); } const { $ } = await crawl(`https://eztv.wf/shows/${show}/`); const episodes = $('[name="hover"]').toArray(); const imdbIdRegex = $('[itemprop="aggregateRating"] a').attr('href')?.match(/tt\d+/); const result = { title: $('.section_post_header [itemprop="name"]').text(), summary: $('[itemprop="description"] p').text(), description: $('span[itemprop="description"] + br + br + hr + br + span').text(), imdbId: imdbIdRegex ? imdbIdRegex[0] : null, episodes: episodes.map(episode => { return transformToEpisode($, episode); }) } if (!result || !result.title || result.title === '') { throw new EztvCrawlerException(`Did not find a show with name ${show}`) } return result; } /** * Search for a TV show episode * * @param query - string * @returns `episodeObject` */ export async function search(query: string) { const { $ } = await crawl(`https://eztv.wf/search/${query}`); const episodes = $('[name="hover"]').toArray(); return episodes.map(episode => { return transformToEpisode($, episode); }) } /** * Get a list of torrents * * @param limit * @param page * @param apiBaseUrl - If eztv domain changed or eztv is blocked in your country provide a proxy url here * @returns `ApiResponseType` */ export async function getTorrents(limit = 10, page = 1, apiBaseUrl = 'https://eztv.wf/api/') { return await makeApiRequest('/get-torrents', { limit: limit.toString(), page: page.toString() }, apiBaseUrl); } /** * Get a list of torrents based on IMDb ID * * NOTE: * For TV Shows provide the IMDb id of the show itself, it does not work * when you provide an IMDb for individual episodes * * @param imdbId - IMDb ID * @param apiBaseUrl - If eztv domain changed or eztv is blocked in your country provide a proxy url here * @returns `ApiResponseType` */ export async function getTorrentsByImdbId(imdbId: string, limit = 30, page = 1, apiBaseUrl = 'https://eztv.wf/api/') { return await makeApiRequest('/get-torrents', { imdb_id: imdbId, limit: limit.toString(), page: page.toString() }, apiBaseUrl); } /** * Send a request to EZTV's API * * @param path * @param params * @param apiBaseUrl * @returns `ApiResponseType` */ async function makeApiRequest(path: string, params: Record, apiBaseUrl = 'https://eztv.wf/api/') { if (params.imdb_id) { params.imdb_id = params.imdb_id.replace(/\D+/, ''); } try { const request = await fetch(`${apiBaseUrl}/${path}?${new URLSearchParams(params)}`); const json: ApiResponseType = await request.json(); return json; } catch(e) { if(e instanceof Error) { throw new EztvCrawlerException(e.message); } throw new EztvCrawlerException('Could not fullfill request.'); } } /** * Transforms a Element into a episode object * * Note: in torrents, don't parse the `.download_2` class, it contains spam and malware in some cases * * @param $ - cheerio.CheerioAPI * @param episode - cheerio.Element * @returns `episodeObject` */ function transformToEpisode($: CheerioAPI, episode: Element) { return { showLink: $(episode).find('td:nth-child(1) a').attr('href'), title: $(episode).find('td:nth-child(2)').text()?.replace(/\n/g, ''), magnet: $(episode).find('td:nth-child(3) .magnet').attr('href')?.replace(/\n/g, ''), torrent: $(episode).find('td:nth-child(3) .download_1').attr('href')?.replace(/\n/g, ''), size: bytes($(episode).find('td:nth-child(4)').text()), released: $(episode).find('td:nth-child(5)').text(), seeds: parseInt($(episode).find('td:nth-child(6)').text()) || 0 } } export class EztvCrawlerException extends Error {}